<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.npiontko.pro/feed.xml" rel="self" type="application/atom+xml"/><link href="https://www.npiontko.pro/" rel="alternate" type="text/html"/><updated>2026-05-06T19:52:50+00:00</updated><id>https://www.npiontko.pro/feed.xml</id><title type="html">NP Blog</title><subtitle>Hi there! I'm Nazarii Piontko, a software engineer who loves to solve problems with code. On my blog, I share my tech discoveries, research findings, and hands-on experiments.</subtitle><author><name>Nazarii Piontko</name></author><entry><title type="html">Calling Async Rust from C#: Tokio, Callbacks, and Cancellation</title><link href="https://www.npiontko.pro/2026/04/26/rust-csharp-async-interop" rel="alternate" type="text/html" title="Calling Async Rust from C#: Tokio, Callbacks, and Cancellation"/><published>2026-04-26T00:00:00+00:00</published><updated>2026-04-26T00:00:00+00:00</updated><id>https://www.npiontko.pro/2026/04/26/rust-csharp-async-interop</id><content type="html" xml:base="https://www.npiontko.pro/2026/04/26/rust-csharp-async-interop"><![CDATA[<p>Rust↔C# interop is reasonably well-documented for the simple case: define <code class="language-plaintext highlighter-rouge">extern "C" fn add(a: i32, b: i32) -&gt; i32</code>, P/Invoke it from C#, done. The async case is much less covered, and the few resources that exist tend to stop at “spawn a Tokio task and call back into C#” without spelling out the lifetime, GC, and cancellation details that make a production library actually work. That gap is what I want to fill here, because it’s where I had to figure most of this out by trial and error.</p> <p>What follows is a walk through one set of solutions: a Tokio runtime owned by the .NET process, an FFI callback bridge that completes a <code class="language-plaintext highlighter-rouge">Task</code> on the C# side, and a lock-free state machine that keeps cancellation safe across the boundary. The running example is <a href="https://github.com/nazarii-piontko/datafusion-sharp">DataFusionSharp</a>, a .NET binding for <a href="https://datafusion.apache.org/">Apache DataFusion</a> — DataFusion is async to its core (every query, file scan, and object-store request returns a <code class="language-plaintext highlighter-rouge">Future</code>), and the binding’s whole job is to expose that as idiomatic <code class="language-plaintext highlighter-rouge">Task&lt;T&gt;</code>-returning C# methods. Tokio sits on one end, the .NET task scheduler on the other, with no shared notion of “completion” between them.</p> <h2 id="async-in-rust-and-c">Async in Rust and C#</h2> <p>Both Rust and C# settled on the same fundamental design: <code class="language-plaintext highlighter-rouge">async</code> functions compile to state machines that drive an external object (<code class="language-plaintext highlighter-rouge">Future</code> in Rust, <code class="language-plaintext highlighter-rouge">Task</code> in C#). The state machine yields control whenever it would block, and resumes when an “awaitable” completes. The vocabulary differs but the mechanics are basically the same.</p> <p>In Rust, <code class="language-plaintext highlighter-rouge">async fn foo()</code> is sugar that the compiler rewrites into a function returning <code class="language-plaintext highlighter-rouge">impl Future&lt;Output = T&gt;</code>. A <code class="language-plaintext highlighter-rouge">Future</code> is a trait with a single method: <code class="language-plaintext highlighter-rouge">poll(self: Pin&lt;&amp;mut Self&gt;, cx: &amp;mut Context&lt;'_&gt;) -&gt; Poll&lt;T&gt;</code>. Polling either returns <code class="language-plaintext highlighter-rouge">Ready(value)</code> or <code class="language-plaintext highlighter-rouge">Pending</code>, and on <code class="language-plaintext highlighter-rouge">Pending</code> the future has registered a <code class="language-plaintext highlighter-rouge">Waker</code> somewhere so it can be polled again later. Crucially, <em>nothing polls a <code class="language-plaintext highlighter-rouge">Future</code> until you hand it to an executor</em>. The most popular executor is <a href="https://tokio.rs/">Tokio</a>, a multi-threaded scheduler that owns a thread pool, drives futures to completion, and provides timers and an I/O reactor.</p> <p>In C#, <code class="language-plaintext highlighter-rouge">async Task&lt;T&gt;</code> is also sugar — the compiler rewrites the body into a state machine implementing <code class="language-plaintext highlighter-rouge">IAsyncStateMachine</code>, with <code class="language-plaintext highlighter-rouge">MoveNext()</code> standing in for Rust’s <code class="language-plaintext highlighter-rouge">poll</code> (almost, C# is push-based while Rust is pull-based). The difference is that the state machine is wired up automatically: continuations land on the .NET thread pool by default, awaiters are part of the BCL, and there is no executor to choose. You just call an <code class="language-plaintext highlighter-rouge">async</code> method and <code class="language-plaintext highlighter-rouge">await</code> what it returns.</p> <p>Both compilers are doing the similar transformation, but the resulting state machine has to be driven by something, and Rust and C# don’t share an executor, a poll loop, or a notion of completion. A Rust <code class="language-plaintext highlighter-rouge">Future</code> can’t be awaited from C# directly, and shipping Tokio’s runtime into .NET as a managed scheduler is not on the table either. So something has to bridge the two sides.</p> <h2 id="the-bridge-callbacks-at-the-ffi-boundary">The Bridge: Callbacks at the FFI Boundary</h2> <p>The lowest common denominator both sides understand is a C-style callback. The pattern looks like this:</p> <ol> <li>C# calls a native function and that function returns immediately.</li> <li>Native code spawns a Tokio task that does the actual async work.</li> <li>When the task finishes, it invokes a callback function pointer the C# side passed in.</li> <li>The callback unblocks a <code class="language-plaintext highlighter-rouge">TaskCompletionSource</code> on the C# side, which completes the <code class="language-plaintext highlighter-rouge">Task</code> the original caller is awaiting.</li> </ol> <p>The native function returns synchronously and immediately — it’s just a “start this work” message. Everything else happens through the callback.</p> <p>Here’s the possible callback signature on the Rust side:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">type</span> <span class="n">Callback</span> <span class="o">=</span> <span class="k">unsafe</span> <span class="k">extern</span> <span class="s">"C"</span> <span class="k">fn</span><span class="p">(</span>
    <span class="n">result</span><span class="p">:</span> <span class="o">*</span><span class="k">const</span> <span class="nn">std</span><span class="p">::</span><span class="nn">ffi</span><span class="p">::</span><span class="nb">c_void</span><span class="p">,</span>
    <span class="n">error</span><span class="p">:</span> <span class="o">*</span><span class="k">const</span> <span class="n">ErrorInfoData</span><span class="p">,</span>
    <span class="n">user_data</span><span class="p">:</span> <span class="nb">isize</span><span class="p">,</span>
<span class="p">);</span>
</code></pre></div></div> <p>Three pieces:</p> <ul> <li><strong><code class="language-plaintext highlighter-rouge">result</code></strong> — pointer to whatever the operation produced, or null on error.</li> <li><strong><code class="language-plaintext highlighter-rouge">error</code></strong> — pointer to error info, or null on success.</li> <li><strong><code class="language-plaintext highlighter-rouge">user_data</code></strong> — an opaque <code class="language-plaintext highlighter-rouge">isize</code> the caller passed in. Native code never looks at it, it’s just round-tripped back to C#. This is how we identify <em>which</em> operation just completed, and the callback later turns it back into a managed object — the section on <code class="language-plaintext highlighter-rouge">GCHandle</code> below covers that part.</li> </ul> <h2 id="wire-format-and-lifetimes">Wire Format and Lifetimes</h2> <p>Two <code class="language-plaintext highlighter-rouge">#[repr(C)]</code> structs do all the heavy lifting for data crossing the boundary:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">#[repr(C)]</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">BytesData</span> <span class="p">{</span>
    <span class="n">data</span><span class="p">:</span> <span class="o">*</span><span class="k">const</span> <span class="nb">u8</span><span class="p">,</span>
    <span class="n">len</span><span class="p">:</span> <span class="nb">u32</span><span class="p">,</span>
<span class="p">}</span>

<span class="nd">#[repr(C)]</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">ErrorInfoData</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">code</span><span class="p">:</span> <span class="n">ErrorCode</span><span class="p">,</span>
    <span class="k">pub</span> <span class="n">message</span><span class="p">:</span> <span class="n">BytesData</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">#[repr(C)]</code> makes the layout byte-identical to C# structs declared with <code class="language-plaintext highlighter-rouge">[StructLayout(LayoutKind.Sequential)]</code>, which means they pass through P/Invoke with zero marshalling — the bytes on the stack on one side are the bytes on the stack on the other.</p> <p><code class="language-plaintext highlighter-rouge">BytesData</code> is a pointer-and-length pair. It carries arguments going <em>into</em> native code (Protobuf-encoded options, query parameters, raw bytes), and it carries bulk results coming <em>back</em> through the callback’s <code class="language-plaintext highlighter-rouge">result</code> slot. <code class="language-plaintext highlighter-rouge">ErrorInfoData</code> packages a numeric error code with a message in the same shape; the callback’s <code class="language-plaintext highlighter-rouge">error</code> slot is a pointer to one of these when an operation failed.</p> <p>The general rule for FFI lifetimes here:</p> <ul> <li><strong>Inputs</strong>: pinned on the C# side, consumed by Rust before the call returns or before the spawned task finishes. Either way, Rust never holds onto an input pointer past the immediate operation.</li> <li><strong>Outputs</strong>: owned by Rust, exposed to C# only during the callback. C# copies what it needs (<code class="language-plaintext highlighter-rouge">Marshal.PtrToStructure</code> for whole structs, <code class="language-plaintext highlighter-rouge">Marshal.ReadIntPtr</code> for a single pointer, plain copy loops for <code class="language-plaintext highlighter-rouge">BytesData</code> payloads), then Rust frees the original as the callback returns.</li> </ul> <p>This is similar to how borrow checking works in pure Rust — the pointer’s “lifetime” is the duration of the call. The only twist is that the borrow checker can’t see across the FFI boundary, so the convention is enforced by hand on both sides.</p> <h2 id="owning-tokio-from-c">Owning Tokio from C#</h2> <p>DataFusionSharp creates and owns the Tokio runtime explicitly, so the C# side has full control over its lifetime.</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">type</span> <span class="n">RuntimeHandle</span> <span class="o">=</span> <span class="nb">Arc</span><span class="o">&lt;</span><span class="nn">tokio</span><span class="p">::</span><span class="nn">runtime</span><span class="p">::</span><span class="n">Runtime</span><span class="o">&gt;</span><span class="p">;</span>

<span class="nd">#[unsafe(no_mangle)]</span>
<span class="k">pub</span> <span class="k">unsafe</span> <span class="k">extern</span> <span class="s">"C"</span> <span class="k">fn</span> <span class="nf">datafusion_runtime_new</span><span class="p">(</span>
    <span class="n">runtime_ptr</span><span class="p">:</span> <span class="o">*</span><span class="k">mut</span> <span class="o">*</span><span class="k">mut</span> <span class="n">RuntimeHandle</span><span class="p">,</span>
<span class="p">)</span> <span class="k">-&gt;</span> <span class="n">ErrorCode</span> <span class="p">{</span>
    <span class="k">let</span> <span class="k">mut</span> <span class="n">builder</span> <span class="o">=</span> <span class="nn">tokio</span><span class="p">::</span><span class="nn">runtime</span><span class="p">::</span><span class="nn">Builder</span><span class="p">::</span><span class="nf">new_multi_thread</span><span class="p">();</span>

    <span class="n">builder</span><span class="nf">.enable_all</span><span class="p">();</span>

    <span class="k">match</span> <span class="n">builder</span><span class="nf">.build</span><span class="p">()</span> <span class="p">{</span>
        <span class="nf">Ok</span><span class="p">(</span><span class="n">runtime</span><span class="p">)</span> <span class="k">=&gt;</span> <span class="p">{</span>
            <span class="k">let</span> <span class="n">runtime_handle</span><span class="p">:</span> <span class="n">RuntimeHandle</span> <span class="o">=</span> <span class="nn">Arc</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="n">runtime</span><span class="p">);</span>
            <span class="k">unsafe</span> <span class="p">{</span> <span class="o">*</span><span class="n">runtime_ptr</span> <span class="o">=</span> <span class="nn">Box</span><span class="p">::</span><span class="nf">into_raw</span><span class="p">(</span><span class="nn">Box</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="n">runtime_handle</span><span class="p">));</span> <span class="p">}</span>
            <span class="nn">ErrorCode</span><span class="p">::</span><span class="nb">Ok</span>
        <span class="p">}</span>
        <span class="nf">Err</span><span class="p">(</span><span class="n">_</span><span class="p">)</span> <span class="k">=&gt;</span> <span class="nn">ErrorCode</span><span class="p">::</span><span class="n">RuntimeInitializationFailed</span><span class="p">,</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>A few things worth pointing out:</p> <ul> <li>The runtime is wrapped in <code class="language-plaintext highlighter-rouge">Arc</code>, then boxed and returned as a raw pointer. The <code class="language-plaintext highlighter-rouge">Arc</code> lets multiple objects hold their own reference without complex lifetime management. The raw pointer is what crosses the FFI boundary.</li> <li><code class="language-plaintext highlighter-rouge">new_multi_thread().enable_all()</code> gives us a real, multi-threaded Tokio with timers, I/O, and the works. <code class="language-plaintext highlighter-rouge">enable_all()</code> is a single call that turns on every optional Tokio feature: the timer wheel, the I/O reactor for sockets and files, and the signal handlers. Without it, <code class="language-plaintext highlighter-rouge">tokio::time::sleep</code> would panic at runtime.</li> </ul> <p>On the C# side, we wrap the raw pointer in a <code class="language-plaintext highlighter-rouge">SafeHandle</code> so it gets cleaned up even if the C# code forgets to dispose explicitly. <code class="language-plaintext highlighter-rouge">SafeHandle</code> makes the handle GC-aware. It also guarantees that the handle is not freed while another thread is in the middle of a P/Invoke that uses it — the runtime ref-counts active calls and only allows <code class="language-plaintext highlighter-rouge">ReleaseHandle</code> once the count drops to zero. That eliminates a whole category of use-after-free bugs at the FFI boundary, with no extra code on our part.</p> <h2 id="the-smallest-async-operation-ping">The Smallest Async Operation: Ping</h2> <p>Before tackling SQL, file I/O, and cancellation, here is the simplest possible async FFI call: a <code class="language-plaintext highlighter-rouge">ping</code> that sleeps for a specified number of milliseconds and then signals completion. It exercises every piece of the Rust-side machinery — runtime, spawn, select, callback. The next section covers the matching C# half.</p> <p>Rust side:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">#[unsafe(no_mangle)]</span>
<span class="k">pub</span> <span class="k">unsafe</span> <span class="k">extern</span> <span class="s">"C"</span> <span class="k">fn</span> <span class="nf">datafusion_ping</span><span class="p">(</span>
    <span class="n">runtime_ptr</span><span class="p">:</span> <span class="o">*</span><span class="k">mut</span> <span class="n">RuntimeHandle</span><span class="p">,</span>
    <span class="n">timeout_millis</span><span class="p">:</span> <span class="nb">u64</span><span class="p">,</span>
    <span class="n">callback</span><span class="p">:</span> <span class="n">Callback</span><span class="p">,</span>
    <span class="n">user_data</span><span class="p">:</span> <span class="nb">isize</span><span class="p">,</span>
    <span class="n">cancellation_token_out_ptr</span><span class="p">:</span> <span class="o">*</span><span class="k">mut</span> <span class="o">*</span><span class="k">mut</span> <span class="n">CancellationToken</span><span class="p">,</span>
<span class="p">)</span> <span class="k">-&gt;</span> <span class="n">ErrorCode</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">runtime</span> <span class="o">=</span> <span class="nd">ffi_ref!</span><span class="p">(</span><span class="n">runtime_ptr</span><span class="p">);</span>

    <span class="k">let</span> <span class="n">cancellation_token</span> <span class="o">=</span> <span class="nn">CancellationToken</span><span class="p">::</span><span class="nf">new</span><span class="p">();</span>
    <span class="k">crate</span><span class="p">::</span><span class="nn">cancellation</span><span class="p">::</span><span class="nf">into_raw_ptr</span><span class="p">(</span><span class="o">&amp;</span><span class="n">cancellation_token</span><span class="p">,</span> <span class="n">cancellation_token_out_ptr</span><span class="p">);</span>

    <span class="n">runtime</span><span class="nf">.spawn</span><span class="p">(</span><span class="k">async</span> <span class="k">move</span> <span class="p">{</span>
        <span class="k">let</span> <span class="n">result</span> <span class="o">=</span> <span class="nn">tokio</span><span class="p">::</span><span class="nd">select!</span> <span class="p">{</span>
            <span class="p">()</span> <span class="o">=</span> <span class="nn">tokio</span><span class="p">::</span><span class="nn">time</span><span class="p">::</span><span class="nf">sleep</span><span class="p">(</span><span class="nn">Duration</span><span class="p">::</span><span class="nf">from_millis</span><span class="p">(</span><span class="n">timeout_millis</span><span class="p">))</span> <span class="k">=&gt;</span> <span class="nf">Ok</span><span class="p">(()),</span>
            <span class="p">()</span> <span class="o">=</span> <span class="n">cancellation_token</span><span class="nf">.cancelled</span><span class="p">()</span> <span class="k">=&gt;</span> <span class="nf">Err</span><span class="p">(</span><span class="k">crate</span><span class="p">::</span><span class="nn">cancellation</span><span class="p">::</span><span class="nf">error</span><span class="p">()),</span>
        <span class="p">};</span>

        <span class="k">crate</span><span class="p">::</span><span class="nf">invoke_callback</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="n">callback</span><span class="p">,</span> <span class="n">user_data</span><span class="p">);</span>
    <span class="p">});</span>

    <span class="nn">ErrorCode</span><span class="p">::</span><span class="nb">Ok</span>
<span class="p">}</span>
</code></pre></div></div> <p>Four things happen here:</p> <ol> <li>A Tokio <code class="language-plaintext highlighter-rouge">CancellationToken</code> is created and a pointer to it is written back through the out parameter (more on this in the cancellation section).</li> <li><code class="language-plaintext highlighter-rouge">runtime.spawn(async move { ... })</code> schedules the actual work on the Tokio thread pool. <code class="language-plaintext highlighter-rouge">spawn</code> returns immediately; the closure runs on a worker thread.</li> <li>Inside the spawned task, <code class="language-plaintext highlighter-rouge">tokio::select!</code> races two futures: the sleep and the cancellation signal. Whichever finishes first wins; the other is dropped. Dropping a future is how Rust says “stop polling it” — <code class="language-plaintext highlighter-rouge">tokio::time::sleep</code> cleans up its timer entry as it’s dropped, so the abandoned branch costs nothing.</li> <li>When done, the C-style callback fires with either the result or an error, passing back the same <code class="language-plaintext highlighter-rouge">user_data</code> we received.</li> </ol> <p>The <code class="language-plaintext highlighter-rouge">extern "C"</code> function itself returns <code class="language-plaintext highlighter-rouge">ErrorCode::Ok</code> <em>synchronously</em>. From C#’s perspective, it just got a “scheduled” acknowledgment — the result will arrive later via the callback.</p> <h2 id="the-c-side-from-callback-to-task">The C# Side: From Callback to Task</h2> <p>The C# side is more involved. Callers expect a <code class="language-plaintext highlighter-rouge">Task</code> they can <code class="language-plaintext highlighter-rouge">await</code>, not a callback, so bridging the two takes three pieces.</p> <h3 id="1-a-taskcompletionsource">1. A TaskCompletionSource</h3> <p><a href="https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcompletionsource"><code class="language-plaintext highlighter-rouge">TaskCompletionSource</code></a> gives us a <code class="language-plaintext highlighter-rouge">Task</code> whose completion we control imperatively. We hand the <code class="language-plaintext highlighter-rouge">Task</code> to the caller, then later call <code class="language-plaintext highlighter-rouge">TrySetResult</code>, <code class="language-plaintext highlighter-rouge">TrySetException</code>, or <code class="language-plaintext highlighter-rouge">TrySetCanceled</code> from inside the callback.</p> <p>The cross-cutting plumbing — the <code class="language-plaintext highlighter-rouge">GCHandle</code> that pins the operation across the FFI call, the <code class="language-plaintext highlighter-rouge">CancellationTokenRegistration</code>, and the three-state pointer slot for the native cancellation handle — lives on an abstract base class that the typed leaf types inherit from:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">internal</span> <span class="k">abstract</span> <span class="k">class</span> <span class="nc">AsyncOperation</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">CancellationToken</span> <span class="n">_cancellationToken</span><span class="p">;</span>
    <span class="k">private</span> <span class="n">CancellationTokenRegistration</span> <span class="n">_cancellationRegistration</span><span class="p">;</span>
    <span class="k">private</span> <span class="n">GCHandle</span> <span class="n">_handle</span><span class="p">;</span>
    <span class="k">private</span> <span class="n">IntPtr</span> <span class="n">_cancellationTokenHandle</span><span class="p">;</span>

    <span class="k">protected</span> <span class="nf">AsyncOperation</span><span class="p">(</span><span class="n">CancellationToken</span> <span class="n">cancellationToken</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_cancellationToken</span> <span class="p">=</span> <span class="n">cancellationToken</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">internal</span> <span class="n">IntPtr</span> <span class="nf">GetHandle</span><span class="p">()</span> <span class="p">{</span> <span class="cm">/* lazy GCHandle.Alloc, returns a stable IntPtr */</span> <span class="p">}</span>
    <span class="k">internal</span> <span class="k">void</span> <span class="nf">EnsureNativeCall</span><span class="p">(...)</span> <span class="p">{</span> <span class="cm">/* validates result, stores the native cancel pointer,
                                              registers OnCancelled, throws if the call failed */</span> <span class="p">}</span>
    <span class="k">protected</span> <span class="k">void</span> <span class="nf">Cleanup</span><span class="p">()</span>       <span class="p">{</span> <span class="cm">/* free GCHandle, destroy native token, unregister */</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>Two leaf types specialise it: <code class="language-plaintext highlighter-rouge">AsyncVoidOperation</code> for fire-and-forget completion, and <code class="language-plaintext highlighter-rouge">AsyncOperation&lt;TResult&gt;</code> for callbacks that yield a typed value. Each adds a <code class="language-plaintext highlighter-rouge">TaskCompletionSource</code> of the matching shape and a <code class="language-plaintext highlighter-rouge">Complete(...)</code> method that translates the callback’s result or exception into the right <code class="language-plaintext highlighter-rouge">TrySet*</code> call:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">internal</span> <span class="k">sealed</span> <span class="k">class</span> <span class="nc">AsyncVoidOperation</span> <span class="p">:</span> <span class="n">AsyncOperation</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">TaskCompletionSource</span> <span class="n">_taskCompletionSource</span> <span class="p">=</span>
        <span class="k">new</span><span class="p">(</span><span class="n">TaskCreationOptions</span><span class="p">.</span><span class="n">RunContinuationsAsynchronously</span><span class="p">);</span>

    <span class="k">internal</span> <span class="n">Task</span> <span class="n">Task</span> <span class="p">=&gt;</span> <span class="n">_taskCompletionSource</span><span class="p">.</span><span class="n">Task</span><span class="p">;</span>

    <span class="k">internal</span> <span class="k">void</span> <span class="nf">Complete</span><span class="p">(</span><span class="n">Exception</span><span class="p">?</span> <span class="n">exception</span> <span class="p">=</span> <span class="k">null</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="nf">Cleanup</span><span class="p">();</span>

        <span class="k">switch</span> <span class="p">(</span><span class="n">exception</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">case</span> <span class="k">null</span><span class="p">:</span>
                <span class="n">_taskCompletionSource</span><span class="p">.</span><span class="nf">TrySetResult</span><span class="p">();</span>
                <span class="k">break</span><span class="p">;</span>
            <span class="k">case</span> <span class="n">DataFusionException</span> <span class="p">{</span> <span class="n">ErrorCode</span><span class="p">:</span> <span class="n">DataFusionErrorCode</span><span class="p">.</span><span class="n">Canceled</span> <span class="p">}:</span>
                <span class="n">_taskCompletionSource</span><span class="p">.</span><span class="nf">TrySetCanceled</span><span class="p">();</span>
                <span class="k">break</span><span class="p">;</span>
            <span class="k">default</span><span class="p">:</span>
                <span class="n">_taskCompletionSource</span><span class="p">.</span><span class="nf">TrySetException</span><span class="p">(</span><span class="n">exception</span><span class="p">);</span>
                <span class="k">break</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">TaskCreationOptions.RunContinuationsAsynchronously</code> matters here. Without it, calling <code class="language-plaintext highlighter-rouge">TrySetResult</code> synchronously dispatches the awaiter’s continuation on whatever thread invoked the callback — which is a Tokio worker thread. Continuations belong on the .NET thread pool, not on Tokio’s; running them inline can starve Tokio workers and even deadlock if the continuation does any blocking I/O of its own. With the flag set, the runtime queues the continuation through <code class="language-plaintext highlighter-rouge">ThreadPool.UnsafeQueueUserWorkItem</code>, the Tokio worker returns to its scheduler immediately, and the C# code wakes up on a managed thread. Both sides go back to running their own jobs.</p> <h3 id="2-a-gchandle-as-user_data">2. A GCHandle as user_data</h3> <p>How does the native callback know <em>which</em> <code class="language-plaintext highlighter-rouge">AsyncVoidOperation</code> to complete? Answer is <code class="language-plaintext highlighter-rouge">user_data: isize</code> slot. It’s perfect for an opaque managed-object identifier — and <code class="language-plaintext highlighter-rouge">GCHandle</code> is exactly that.</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">internal</span> <span class="n">IntPtr</span> <span class="nf">GetHandle</span><span class="p">()</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(!</span><span class="n">_handle</span><span class="p">.</span><span class="n">IsAllocated</span><span class="p">)</span>
        <span class="n">_handle</span> <span class="p">=</span> <span class="n">GCHandle</span><span class="p">.</span><span class="nf">Alloc</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="n">GCHandleType</span><span class="p">.</span><span class="n">Normal</span><span class="p">);</span>

    <span class="k">return</span> <span class="n">GCHandle</span><span class="p">.</span><span class="nf">ToIntPtr</span><span class="p">(</span><span class="n">_handle</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">GCHandle.Alloc</code> does two things: it keeps the C# object alive even when nothing managed references it, and it gives us a stable <code class="language-plaintext highlighter-rouge">IntPtr</code> we can hand to native code. When the callback fires, it passes that <code class="language-plaintext highlighter-rouge">IntPtr</code> back, we call <code class="language-plaintext highlighter-rouge">GCHandle.FromIntPtr</code> to recover the original object, and free the handle.</p> <p>This is the standard way to round-trip a managed object identity through unmanaged code. Without it, the GC could collect the <code class="language-plaintext highlighter-rouge">AsyncVoidOperation</code> between the call and the callback, leaving the callback with nothing valid to complete. <code class="language-plaintext highlighter-rouge">GCHandleType.Normal</code> keeps the object alive but does not pin its memory — there’s no need to, because we never read its bytes from native code, only its identity.</p> <h3 id="3-an-unmanagedcallersonly-callback">3. An UnmanagedCallersOnly Callback</h3> <p>The actual callback function needs to be a plain function pointer with the C calling convention.</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="nf">UnmanagedCallersOnly</span><span class="p">(</span><span class="n">CallConvs</span> <span class="p">=</span> <span class="p">[</span><span class="k">typeof</span><span class="p">(</span><span class="n">CallConvCdecl</span><span class="p">)])]</span>
<span class="k">internal</span> <span class="k">static</span> <span class="k">void</span> <span class="nf">CallbackForVoid</span><span class="p">(</span><span class="n">IntPtr</span> <span class="n">_</span><span class="p">,</span> <span class="n">IntPtr</span> <span class="n">error</span><span class="p">,</span> <span class="n">IntPtr</span> <span class="n">handle</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">ex</span> <span class="p">=</span> <span class="n">error</span> <span class="p">!=</span> <span class="n">IntPtr</span><span class="p">.</span><span class="n">Zero</span>
        <span class="p">?</span> <span class="n">ErrorInfoData</span><span class="p">.</span><span class="nf">FromIntPtr</span><span class="p">(</span><span class="n">error</span><span class="p">).</span><span class="nf">ToException</span><span class="p">()</span>
        <span class="p">:</span> <span class="k">null</span><span class="p">;</span>
    <span class="kt">var</span> <span class="n">op</span> <span class="p">=</span> <span class="n">AsyncVoidOperation</span><span class="p">.</span><span class="nf">FromHandle</span><span class="p">(</span><span class="n">handle</span><span class="p">);</span>
    <span class="n">op</span><span class="p">?.</span><span class="nf">Complete</span><span class="p">(</span><span class="n">ex</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div> <p><a href="https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.unmanagedcallersonlyattribute"><code class="language-plaintext highlighter-rouge">[UnmanagedCallersOnly]</code></a> tells the runtime: this method must be callable directly as a C function pointer. The compiler verifies that all parameter types are blittable and that the body doesn’t take a managed reference to itself. The result is a function pointer you can hand to native code with no marshalling overhead.</p> <p><code class="language-plaintext highlighter-rouge">FromHandle</code> does the inverse of <code class="language-plaintext highlighter-rouge">GetHandle</code>: it converts the <code class="language-plaintext highlighter-rouge">IntPtr</code> back to the managed object.</p> <h3 id="putting-it-together">Putting It Together</h3> <p>With those three pieces, the entire managed <code class="language-plaintext highlighter-rouge">PingAsync</code> looks like this:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">internal</span> <span class="n">Task</span> <span class="nf">PingAsync</span><span class="p">(</span><span class="n">TimeSpan</span> <span class="n">timeout</span><span class="p">,</span> <span class="n">CancellationToken</span> <span class="n">cancellationToken</span> <span class="p">=</span> <span class="k">default</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">unsafe</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">op</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">AsyncVoidOperation</span><span class="p">(</span><span class="n">cancellationToken</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">result</span> <span class="p">=</span> <span class="n">NativeMethods</span><span class="p">.</span><span class="nf">Ping</span><span class="p">(</span>
            <span class="n">_handle</span><span class="p">,</span>
            <span class="p">(</span><span class="kt">ulong</span><span class="p">)</span> <span class="n">timeout</span><span class="p">.</span><span class="n">TotalMilliseconds</span><span class="p">,</span>
            <span class="p">&amp;</span><span class="n">GenericCallbacks</span><span class="p">.</span><span class="n">CallbackForVoid</span><span class="p">,</span>
            <span class="n">op</span><span class="p">.</span><span class="nf">GetHandle</span><span class="p">(),</span>
            <span class="k">out</span> <span class="kt">var</span> <span class="n">cancellationTokenHandle</span><span class="p">);</span>
        <span class="n">op</span><span class="p">.</span><span class="nf">EnsureNativeCall</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="n">cancellationTokenHandle</span><span class="p">,</span> <span class="s">"Failed to start ping."</span><span class="p">);</span>

        <span class="k">return</span> <span class="n">op</span><span class="p">.</span><span class="n">Task</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>The <code class="language-plaintext highlighter-rouge">&amp;GenericCallbacks.CallbackForVoid</code> syntax produces a real function pointer (this is C# 9’s function-pointer feature, which works with <code class="language-plaintext highlighter-rouge">[UnmanagedCallersOnly]</code> methods). We pass the <code class="language-plaintext highlighter-rouge">GCHandle</code> as <code class="language-plaintext highlighter-rouge">user_data</code>, and we get back a <code class="language-plaintext highlighter-rouge">Task</code> the caller can await.</p> <p><code class="language-plaintext highlighter-rouge">EnsureNativeCall</code> does the post-call bookkeeping that the snippet hides: it stores the cancellation-token pointer the native side returned, registers a callback on the user’s token (<code class="language-plaintext highlighter-rouge">_cancellationToken.Register(OnCancelled)</code>) so user-side cancellation flows back into Rust, and throws if the native call returned an error code instead of <code class="language-plaintext highlighter-rouge">Ok</code>. Why that bookkeeping is non-trivial becomes clear in the cancellation section below.</p> <p>That’s the complete request → spawn → callback → Task lifecycle. For an operation returning a value, only one piece changes: a typed <code class="language-plaintext highlighter-rouge">AsyncOperation&lt;TResult&gt;</code> wraps a <code class="language-plaintext highlighter-rouge">TaskCompletionSource&lt;TResult&gt;</code>, and the matching callback parses the <code class="language-plaintext highlighter-rouge">result</code> pointer into a <code class="language-plaintext highlighter-rouge">TResult</code> before completing.</p> <h2 id="returning-a-typed-result-sql">Returning a Typed Result: SQL</h2> <p>Once the void case is in place, returning a typed result is mostly mechanical. Here’s what <code class="language-plaintext highlighter-rouge">SqlAsync</code> looks like — a query that produces a <code class="language-plaintext highlighter-rouge">DataFrame</code>:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">async</span> <span class="n">Task</span><span class="p">&lt;</span><span class="n">DataFrame</span><span class="p">&gt;</span> <span class="nf">SqlAsync</span><span class="p">(</span><span class="kt">string</span> <span class="n">sql</span><span class="p">,</span> <span class="n">CancellationToken</span> <span class="n">cancellationToken</span> <span class="p">=</span> <span class="k">default</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">Task</span><span class="p">&lt;</span><span class="n">DataFrameSafeHandle</span><span class="p">&gt;</span> <span class="n">sqlTask</span><span class="p">;</span>

    <span class="k">unsafe</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">op</span> <span class="p">=</span> <span class="k">new</span> <span class="n">AsyncOperation</span><span class="p">&lt;</span><span class="n">DataFrameSafeHandle</span><span class="p">&gt;(</span><span class="n">cancellationToken</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">result</span> <span class="p">=</span> <span class="n">NativeMethods</span><span class="p">.</span><span class="nf">ContextSql</span><span class="p">(</span>
            <span class="n">_handle</span><span class="p">,</span>
            <span class="n">sql</span><span class="p">,</span>
            <span class="n">BytesData</span><span class="p">.</span><span class="n">Empty</span><span class="p">,</span>
            <span class="p">&amp;</span><span class="n">CallbackForSqlAsync</span><span class="p">,</span>
            <span class="n">op</span><span class="p">.</span><span class="nf">GetHandle</span><span class="p">(),</span>
            <span class="k">out</span> <span class="kt">var</span> <span class="n">cancellationTokenHandle</span><span class="p">);</span>
        <span class="n">op</span><span class="p">.</span><span class="nf">EnsureNativeCall</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="n">cancellationTokenHandle</span><span class="p">,</span> <span class="s">"Failed to start executing SQL query."</span><span class="p">);</span>
        <span class="n">sqlTask</span> <span class="p">=</span> <span class="n">op</span><span class="p">.</span><span class="n">Task</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="kt">var</span> <span class="n">dataFrameSafeHandle</span> <span class="p">=</span> <span class="k">await</span> <span class="n">sqlTask</span><span class="p">.</span><span class="nf">ConfigureAwait</span><span class="p">(</span><span class="k">false</span><span class="p">);</span>
    <span class="k">return</span> <span class="k">new</span> <span class="nf">DataFrame</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="n">dataFrameSafeHandle</span><span class="p">);</span>
<span class="p">}</span>

<span class="p">[</span><span class="nf">UnmanagedCallersOnly</span><span class="p">(</span><span class="n">CallConvs</span> <span class="p">=</span> <span class="p">[</span><span class="k">typeof</span><span class="p">(</span><span class="n">CallConvCdecl</span><span class="p">)])]</span>
<span class="k">private</span> <span class="k">static</span> <span class="k">void</span> <span class="nf">CallbackForSqlAsync</span><span class="p">(</span><span class="n">IntPtr</span> <span class="n">result</span><span class="p">,</span> <span class="n">IntPtr</span> <span class="n">error</span><span class="p">,</span> <span class="n">IntPtr</span> <span class="n">handle</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">op</span> <span class="p">=</span> <span class="n">AsyncOperation</span><span class="p">&lt;</span><span class="n">DataFrameSafeHandle</span><span class="p">&gt;.</span><span class="nf">FromHandle</span><span class="p">(</span><span class="n">handle</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">error</span> <span class="p">!=</span> <span class="n">IntPtr</span><span class="p">.</span><span class="n">Zero</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">op</span><span class="p">?.</span><span class="nf">Complete</span><span class="p">(</span><span class="n">ErrorInfoData</span><span class="p">.</span><span class="nf">FromIntPtr</span><span class="p">(</span><span class="n">error</span><span class="p">).</span><span class="nf">ToException</span><span class="p">());</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="kt">var</span> <span class="n">dataFrameHandle</span> <span class="p">=</span> <span class="n">Marshal</span><span class="p">.</span><span class="nf">ReadIntPtr</span><span class="p">(</span><span class="n">result</span><span class="p">);</span>
    <span class="kt">var</span> <span class="n">safe</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DataFrameSafeHandle</span><span class="p">(</span><span class="n">dataFrameHandle</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">op</span> <span class="k">is</span> <span class="k">null</span><span class="p">)</span>
        <span class="n">safe</span><span class="p">.</span><span class="nf">Dispose</span><span class="p">();</span> <span class="c1">// Nobody's waiting — free the native handle ourselves.</span>
    <span class="k">else</span>
        <span class="n">op</span><span class="p">.</span><span class="nf">Complete</span><span class="p">(</span><span class="n">safe</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div> <p>The Rust side pushes a <code class="language-plaintext highlighter-rouge">*mut DataFrameWrapper</code> through <code class="language-plaintext highlighter-rouge">result</code>. On the C# side we read the pointer with <code class="language-plaintext highlighter-rouge">Marshal.ReadIntPtr</code>, wrap it in a <code class="language-plaintext highlighter-rouge">DataFrameSafeHandle</code>, and complete the operation.</p> <p>The pattern of “callback receives a raw pointer to a result struct, C# wraps it” generalizes to anything: bytes, integers, strings, even FFI Apache Arrow schemas. Each result type gets its own callback function but they all share the same shape.</p> <h2 id="cancellation">Cancellation</h2> <p>Now the reverse direction: a C# caller wants to cancel an in-flight operation by triggering its .NET <code class="language-plaintext highlighter-rouge">CancellationToken</code>. The cancellation needs to flow back through the FFI boundary into the running Tokio task.</p> <p>(A naming note before going further: both Rust and .NET happen to call this primitive <code class="language-plaintext highlighter-rouge">CancellationToken</code>. To keep them straight, I’ll prefix every mention as either <em>Tokio</em> or <em>.NET</em> below.)</p> <p>Tokio already has the right primitive in <a href="https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html"><code class="language-plaintext highlighter-rouge">tokio_util::sync::CancellationToken</code></a> — a token whose <code class="language-plaintext highlighter-rouge">cancelled()</code> future resolves when <code class="language-plaintext highlighter-rouge">cancel()</code> is called from anywhere. Plug that into <code class="language-plaintext highlighter-rouge">tokio::select!</code> (you saw it in the ping function above), and you have an async operation that races real work against an external cancel signal.</p> <p>The piece that’s missing is letting C# call <code class="language-plaintext highlighter-rouge">cancel()</code> on that Tokio token. So we expose two more FFI functions:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">#[unsafe(no_mangle)]</span>
<span class="k">pub</span> <span class="k">unsafe</span> <span class="k">extern</span> <span class="s">"C"</span> <span class="k">fn</span> <span class="nf">datafusion_cancellation_token_cancel</span><span class="p">(</span>
    <span class="n">token_ptr</span><span class="p">:</span> <span class="o">*</span><span class="k">mut</span> <span class="n">CancellationToken</span><span class="p">,</span>
<span class="p">)</span> <span class="k">-&gt;</span> <span class="n">ErrorCode</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">token_ptr</span><span class="nf">.is_null</span><span class="p">()</span> <span class="p">{</span> <span class="k">return</span> <span class="nn">ErrorCode</span><span class="p">::</span><span class="n">InvalidArgument</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">let</span> <span class="n">token</span> <span class="o">=</span> <span class="k">unsafe</span> <span class="p">{</span> <span class="nn">Box</span><span class="p">::</span><span class="nf">from_raw</span><span class="p">(</span><span class="n">token_ptr</span><span class="p">)</span> <span class="p">};</span>
    <span class="n">token</span><span class="nf">.cancel</span><span class="p">();</span>
    <span class="nn">ErrorCode</span><span class="p">::</span><span class="nb">Ok</span>
<span class="p">}</span>

<span class="nd">#[unsafe(no_mangle)]</span>
<span class="k">pub</span> <span class="k">unsafe</span> <span class="k">extern</span> <span class="s">"C"</span> <span class="k">fn</span> <span class="nf">datafusion_cancellation_token_destroy</span><span class="p">(</span>
    <span class="n">token_ptr</span><span class="p">:</span> <span class="o">*</span><span class="k">mut</span> <span class="n">CancellationToken</span><span class="p">,</span>
<span class="p">)</span> <span class="k">-&gt;</span> <span class="n">ErrorCode</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">token_ptr</span><span class="nf">.is_null</span><span class="p">()</span> <span class="p">{</span> <span class="k">return</span> <span class="nn">ErrorCode</span><span class="p">::</span><span class="n">InvalidArgument</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">unsafe</span> <span class="p">{</span> <span class="nf">drop</span><span class="p">(</span><span class="nn">Box</span><span class="p">::</span><span class="nf">from_raw</span><span class="p">(</span><span class="n">token_ptr</span><span class="p">))</span> <span class="p">};</span>
    <span class="nn">ErrorCode</span><span class="p">::</span><span class="nb">Ok</span>
<span class="p">}</span>
</code></pre></div></div> <p>When a Rust async function starts, it creates a Tokio <code class="language-plaintext highlighter-rouge">CancellationToken</code>, clones it (cloning a Tokio token shares the same internal cancellation state — it doesn’t make a separate token), and writes a raw pointer to the clone back through the out parameter. The C# side stores that pointer. If the user’s .NET <code class="language-plaintext highlighter-rouge">CancellationToken</code> fires, C# P/Invokes <code class="language-plaintext highlighter-rouge">datafusion_cancellation_token_cancel</code>, which cancels the Tokio token, which makes <code class="language-plaintext highlighter-rouge">cancellation_token.cancelled()</code> resolve in <code class="language-plaintext highlighter-rouge">tokio::select!</code>, which preempts the real future.</p> <p>If the operation completes normally, C# P/Invokes <code class="language-plaintext highlighter-rouge">datafusion_cancellation_token_destroy</code> instead, freeing the Tokio token without firing it.</p> <p>The C# wiring looks straightforward: register a callback on the user’s .NET token that triggers the native cancel function.</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">_cancellationRegistration</span> <span class="p">=</span> <span class="n">_cancellationToken</span><span class="p">.</span><span class="nf">Register</span><span class="p">(</span><span class="n">OnCancelled</span><span class="p">);</span>

<span class="k">private</span> <span class="k">void</span> <span class="nf">OnCancelled</span><span class="p">()</span> <span class="p">=&gt;</span> <span class="nf">Cancel</span><span class="p">();</span>
</code></pre></div></div> <p>Except it isn’t, because there’s a race, and I had to redo this twice before it stopped flaking in tests.</p> <h3 id="the-race">The Race</h3> <p>Step by step, the ping flow goes:</p> <ol> <li>C# calls <code class="language-plaintext highlighter-rouge">Ping</code>, passing a callback and a slot for the cancellation-token pointer.</li> <li>Native code creates the cancellation token, writes its pointer to the out parameter, <strong>and spawns the Tokio task</strong>.</li> <li>The native function returns to C#.</li> <li>C# wires up <code class="language-plaintext highlighter-rouge">cancellationToken.Register(OnCancelled)</code> so cancellation will trigger the native <code class="language-plaintext highlighter-rouge">Cancel</code>.</li> </ol> <p>The problem: between step 2 and step 4, the Tokio task can start, complete, and fire the callback. If that happens, the callback completes the <code class="language-plaintext highlighter-rouge">Task</code> — but the C# side hasn’t yet decided what to do with the cancellation-token pointer. Worse, if the user cancels right after step 4 wires up cancellation, C# might call <code class="language-plaintext highlighter-rouge">Cancel</code> on a token that’s already been freed by the completion path.</p> <p>With a <code class="language-plaintext highlighter-rouge">ping(0)</code> call, the spawned future completes essentially synchronously on a Tokio worker, so this race fires in practice. By the time the calling thread gets back to step 4, the callback has already run, the GC handle has been freed, and the cancellation token would be gone if we destroyed it eagerly.</p> <p>The simplest fix is to take a lock. But this is a hot path; we don’t want a mutex on every async call.</p> <h3 id="the-three-state-pointer">The Three-State Pointer</h3> <p>Instead, the C# <code class="language-plaintext highlighter-rouge">AsyncOperation</code> treats the native cancellation-token pointer as a tiny lock-free state machine with three states:</p> <ul> <li><code class="language-plaintext highlighter-rouge">IntPtr.Zero</code> — no native token registered yet (initial state).</li> <li>A real pointer — token is alive, cancellation can fire.</li> <li><code class="language-plaintext highlighter-rouge">IntPtr(-1)</code> — operation has finished (completed, errored, or cancelled), token has been or will be destroyed.</li> </ul> <p>The transitions are atomic, using <code class="language-plaintext highlighter-rouge">Interlocked.CompareExchange</code> and <code class="language-plaintext highlighter-rouge">Interlocked.Exchange</code>:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">private</span> <span class="kt">bool</span> <span class="nf">TryInitializeCancellationTokenHandle</span><span class="p">(</span><span class="n">IntPtr</span> <span class="n">handle</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">prev</span> <span class="p">=</span> <span class="n">Interlocked</span><span class="p">.</span><span class="nf">CompareExchange</span><span class="p">(</span>
        <span class="k">ref</span> <span class="n">_cancellationTokenHandle</span><span class="p">,</span>
        <span class="n">handle</span><span class="p">,</span>
        <span class="n">EmptyCancellationTokenHandle</span><span class="p">);</span>
    <span class="k">return</span> <span class="n">prev</span> <span class="p">==</span> <span class="n">EmptyCancellationTokenHandle</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">private</span> <span class="n">IntPtr</span> <span class="nf">TakeCancellationTokenHandle</span><span class="p">()</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="n">Interlocked</span><span class="p">.</span><span class="nf">Exchange</span><span class="p">(</span>
        <span class="k">ref</span> <span class="n">_cancellationTokenHandle</span><span class="p">,</span>
        <span class="n">FinishedCancellationTokenHandle</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">TryInitialize</code> runs in <code class="language-plaintext highlighter-rouge">EnsureNativeCall</code> on the calling thread. <code class="language-plaintext highlighter-rouge">Take</code> runs in two places: the cleanup path inside <code class="language-plaintext highlighter-rouge">Complete</code> (callback thread), and the cancellation path inside <code class="language-plaintext highlighter-rouge">Cancel</code> (whichever thread fires the user’s <code class="language-plaintext highlighter-rouge">CancellationToken</code>).</p> <p>Now the race is well-defined. Two scenarios cover what can happen:</p> <ul> <li><strong>C# wins</strong> (the spawned task hasn’t completed yet): <code class="language-plaintext highlighter-rouge">TryInitialize</code> succeeds (CAS from <code class="language-plaintext highlighter-rouge">Zero</code> → real handle), and the cancellation registration is wired. Whichever path fires next — completion or cancellation — calls <code class="language-plaintext highlighter-rouge">Take</code> to read the live pointer, transitions to <code class="language-plaintext highlighter-rouge">Finished</code>, and acts on it.</li> <li><strong>Tokio task wins</strong> (it completes immediately, runs the callback, which transitions the slot to <code class="language-plaintext highlighter-rouge">Finished</code> via cleanup): <code class="language-plaintext highlighter-rouge">TryInitialize</code> sees <code class="language-plaintext highlighter-rouge">Finished</code>, the CAS fails, and C# destroys the token it had been about to register. Cleanup is a no-op.</li> </ul> <p>In both scenarios, cancellation and completion can never both <code class="language-plaintext highlighter-rouge">Take</code> the same live pointer, because <code class="language-plaintext highlighter-rouge">Exchange</code> is atomic — exactly one of them sees the real handle; the other sees <code class="language-plaintext highlighter-rouge">Finished</code> and walks away.</p> <p>The takeaway I’d hold onto from this: whenever an FFI handle has both a completion path and a from-the-outside path that can fire at the same time, two states aren’t enough. A third state — “done, hands off” — and an atomic CAS into the pointer slot itself is usually cheaper than reaching for a mutex.</p> <h2 id="production-notes">Production Notes</h2> <p>A few practical things worth mentioning:</p> <ul> <li><strong><code class="language-plaintext highlighter-rouge">[LibraryImport]</code> over <code class="language-plaintext highlighter-rouge">[DllImport]</code></strong>. The <code class="language-plaintext highlighter-rouge">[LibraryImport]</code> source generator produces marshalling code at compile time, which makes the bindings AOT-compatible.</li> <li><strong><code class="language-plaintext highlighter-rouge">[SuppressGCTransition]</code></strong> on cheap, non-blocking native functions like <code class="language-plaintext highlighter-rouge">CancellationTokenCancel</code>. Skipping the GC transition shaves a few hundred nanoseconds per call. The trade-off is that the call must not block, allocate, or take any time at all in native code, because the GC is effectively paused for its duration. Cancellation is a simple atomic store on Tokio’s side, so it qualifies.</li> </ul> <h2 id="wrapping-up">Wrapping Up</h2> <p>The pattern in five points:</p> <ul> <li>One Tokio runtime, owned by C# for its full lifetime, holding a raw pointer wrapped in a <code class="language-plaintext highlighter-rouge">SafeHandle</code>.</li> <li>Every async FFI call accepts a <code class="language-plaintext highlighter-rouge">(callback, user_data, cancellation_token_out)</code> triple and returns immediately. Real work runs on the Tokio pool.</li> <li>The managed side keeps its operation object alive across the call via <code class="language-plaintext highlighter-rouge">GCHandle</code>, completes a <code class="language-plaintext highlighter-rouge">TaskCompletionSource</code> from a static <code class="language-plaintext highlighter-rouge">[UnmanagedCallersOnly]</code> callback, and frees the handle on the way out.</li> <li>Cancellation flows the other way through a Tokio <code class="language-plaintext highlighter-rouge">CancellationToken</code> whose pointer is round-tripped through a three-state lock-free machine on the C# side, robust against the obvious “callback fires before C# wires cancellation” race.</li> <li>Inputs and outputs cross the FFI boundary as <code class="language-plaintext highlighter-rouge">#[repr(C)]</code> structs of pointers and lengths, with strict ownership rules: inputs are pinned by C#, outputs are owned by Rust and live only for the duration of the callback.</li> </ul> <p>The code lives at <a href="https://github.com/nazarii-piontko/datafusion-sharp">github.com/nazarii-piontko/datafusion-sharp</a> — the runtime and cancellation pieces are in <code class="language-plaintext highlighter-rouge">native/src/runtime.rs</code>, <code class="language-plaintext highlighter-rouge">native/src/cancellation.rs</code>, and <code class="language-plaintext highlighter-rouge">src/DataFusionSharp/Interop/AsyncOperations.cs</code> if you want to see all the production warts (memory-test instrumentation, finalizers, edge cases) that I trimmed for this post.</p> <p>That’s a lot of plumbing for what looks like a simple <code class="language-plaintext highlighter-rouge">await</code>. The payoff is that once it’s set up, the C# side is just <code class="language-plaintext highlighter-rouge">await context.SqlAsync(...)</code> and the Tokio executor is doing real multi-core async work underneath. If you’re binding any other Tokio-based Rust library to .NET, you’ll probably end up with a fairly close variant of this same wiring — the specifics change but the shape is the same.</p>]]></content><author><name>Nazarii Piontko</name></author><category term="rust"/><category term="dotnet"/><category term="csharp"/><category term="ffi"/><category term="interop"/><category term="async"/><category term="tokio"/><category term="datafusionsharp"/><summary type="html"><![CDATA[Rust's Tokio and C#'s Task scheduler don't share a runtime. Bridging async Rust and C# across FFI takes a Tokio runtime owned by .NET, Tasks completed from native callbacks, and safe cancellation across the boundary. Built around DataFusionSharp's Apache DataFusion bindings.]]></summary></entry><entry><title type="html">Query S3 Parquet Files with Dapper and DataFusionSharp</title><link href="https://www.npiontko.pro/2026/04/02/dapper-s3-parquet-datafusion-sharp" rel="alternate" type="text/html" title="Query S3 Parquet Files with Dapper and DataFusionSharp"/><published>2026-04-02T00:00:00+00:00</published><updated>2026-04-02T00:00:00+00:00</updated><id>https://www.npiontko.pro/2026/04/02/dapper-s3-parquet-datafusion-sharp</id><content type="html" xml:base="https://www.npiontko.pro/2026/04/02/dapper-s3-parquet-datafusion-sharp"><![CDATA[<p>Dapper is the go-to micro-ORM for .NET developers. You write SQL, define a class, and Dapper maps rows to objects. It works with PostgreSQL, SQL Server, SQLite — anything that implements <code class="language-plaintext highlighter-rouge">DbConnection</code>. But it has never worked with files on S3. If your data lives in Parquet files in a bucket, you’re back to writing Arrow column accessors, or leaving .NET entirely for a Python notebook.</p> <p>DataFusionSharp’s ADO.NET provider changes that. It wraps the Apache DataFusion query engine in a standard <code class="language-plaintext highlighter-rouge">DbConnection</code>, which means Dapper’s <code class="language-plaintext highlighter-rouge">QueryAsync&lt;T&gt;</code>, anonymous parameter objects, and strongly-typed result mapping all work out of the box. Same patterns you already know — pointed at S3 instead of a database.</p> <p>This post builds a real example: querying the U.S. Energy Information Administration’s power plant data — three related Parquet files stored in a public S3 bucket. We’ll write three-way JOINs across utilities, power plants, and generators to answer questions like: which utilities have the most generation capacity? What does the energy mix look like across states? What are the largest generators in the country?</p> <h2 id="the-adonet-provider">The ADO.NET Provider</h2> <p>The <a href="/2026/02/24/datafusion-sharp">previous two posts</a> covered DataFusionSharp’s core API — runtime, session context, DataFrame — and <a href="/2026/03/23/query-s3-parquet-dotnet-datafusion-sharp">querying S3 data</a> using Arrow batches. That works well for data pipelines, but most .NET application code doesn’t work with Arrow batches. Service layers, reporting code, controllers — they all expect <code class="language-plaintext highlighter-rouge">List&lt;T&gt;</code>.</p> <p>The <code class="language-plaintext highlighter-rouge">DataFusionSharp.Data</code> package bridges this with a standard ADO.NET provider:</p> <ul> <li><code class="language-plaintext highlighter-rouge">DataFusionSharpConnection</code> extends <code class="language-plaintext highlighter-rouge">DbConnection</code></li> <li><code class="language-plaintext highlighter-rouge">DataFusionSharpCommand</code> extends <code class="language-plaintext highlighter-rouge">DbCommand</code></li> <li><code class="language-plaintext highlighter-rouge">DataFusionSharpDataReader</code> extends <code class="language-plaintext highlighter-rouge">DbDataReader</code></li> <li><code class="language-plaintext highlighter-rouge">DataFusionSharpParameter</code> extends <code class="language-plaintext highlighter-rouge">DbParameter</code></li> </ul> <p>Because it implements <code class="language-plaintext highlighter-rouge">System.Data.Common</code> interfaces, any library built on ADO.NET works with DataFusionSharp. Dapper is the obvious one, but it also works with libraries like Insight.Database, or your own repository abstractions built on <code class="language-plaintext highlighter-rouge">DbConnection</code>.</p> <h2 id="the-dataset">The Dataset</h2> <p>We’ll use data from the <a href="https://catalyst.coop/pudl/">Public Utility Data Liberation (PUDL)</a> project, which publishes cleaned U.S. energy data as Parquet files in a public S3 bucket. The three tables we need:</p> <p><strong>Utilities</strong> (<code class="language-plaintext highlighter-rouge">out_eia__yearly_utilities.parquet</code>, 2.3 MB) — utility company records: name, state, entity type. Primary key: <code class="language-plaintext highlighter-rouge">utility_id_eia</code> + <code class="language-plaintext highlighter-rouge">report_date</code>.</p> <p><strong>Plants</strong> (<code class="language-plaintext highlighter-rouge">out_eia__yearly_plants.parquet</code>, 3.2 MB) — power plant records: name, city, state, coordinates, NERC region. Links to its operating utility via <code class="language-plaintext highlighter-rouge">utility_id_eia</code>. Primary key: <code class="language-plaintext highlighter-rouge">plant_id_eia</code> + <code class="language-plaintext highlighter-rouge">report_date</code>.</p> <p><strong>Generators</strong> (<code class="language-plaintext highlighter-rouge">out_eia__yearly_generators.parquet</code>, 10.3 MB) — individual generator units within plants: capacity in MW, fuel type, technology description, net generation, capacity factor. Links to its plant via <code class="language-plaintext highlighter-rouge">plant_id_eia</code>. Primary key: <code class="language-plaintext highlighter-rouge">plant_id_eia</code> + <code class="language-plaintext highlighter-rouge">generator_id</code> + <code class="language-plaintext highlighter-rouge">report_date</code>.</p> <p>The relationship chain is <strong>Utility → Plants → Generators</strong>. A utility operates one or more plants, and each plant has one or more generators. This is a textbook relational structure — exactly the kind of data that JOINs are made for.</p> <h2 id="setting-up">Setting Up</h2> <p>Install the packages:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package DataFusionSharp.Data
dotnet add package Dapper
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">DataFusionSharp.Data</code> pulls in the core <code class="language-plaintext highlighter-rouge">DataFusionSharp</code> package as a dependency.</p> <p>Connect to S3, register the Parquet tables, and create a Dapper-compatible connection:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">DataFusionSharp</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">DataFusionSharp.Data</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">DataFusionSharp.ObjectStore</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Dapper</span><span class="p">;</span>

<span class="k">using</span> <span class="nn">var</span> <span class="n">runtime</span> <span class="p">=</span> <span class="n">DataFusionRuntime</span><span class="p">.</span><span class="nf">Create</span><span class="p">();</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">session</span> <span class="p">=</span> <span class="n">runtime</span><span class="p">.</span><span class="nf">CreateSessionContext</span><span class="p">();</span>

<span class="c1">// Register public S3 bucket - no credentials needed</span>
<span class="n">session</span><span class="p">.</span><span class="nf">RegisterS3ObjectStore</span><span class="p">(</span><span class="s">"s3://pudl.catalyst.coop"</span><span class="p">,</span> <span class="k">new</span> <span class="n">S3ObjectStoreOptions</span>
<span class="p">{</span>
    <span class="n">BucketName</span> <span class="p">=</span> <span class="s">"pudl.catalyst.coop"</span><span class="p">,</span>
    <span class="n">Region</span> <span class="p">=</span> <span class="s">"us-west-2"</span><span class="p">,</span>
    <span class="n">SkipSignature</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>
<span class="p">});</span>

<span class="c1">// Register Parquet tables from S3</span>
<span class="k">await</span> <span class="n">session</span><span class="p">.</span><span class="nf">RegisterParquetAsync</span><span class="p">(</span>
    <span class="s">"utilities"</span><span class="p">,</span>
    <span class="s">"s3://pudl.catalyst.coop/stable/out_eia__yearly_utilities.parquet"</span><span class="p">);</span>
<span class="k">await</span> <span class="n">session</span><span class="p">.</span><span class="nf">RegisterParquetAsync</span><span class="p">(</span>
    <span class="s">"plants"</span><span class="p">,</span>
    <span class="s">"s3://pudl.catalyst.coop/stable/out_eia__yearly_plants.parquet"</span><span class="p">);</span>
<span class="k">await</span> <span class="n">session</span><span class="p">.</span><span class="nf">RegisterParquetAsync</span><span class="p">(</span>
    <span class="s">"generators"</span><span class="p">,</span>
    <span class="s">"s3://pudl.catalyst.coop/stable/out_eia__yearly_generators.parquet"</span><span class="p">);</span>

<span class="c1">// Create ADO.NET connection - this is what Dapper uses</span>
<span class="k">await</span> <span class="k">using</span> <span class="nn">var</span> <span class="n">connection</span> <span class="p">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">AsConnection</span><span class="p">();</span>
</code></pre></div></div> <p>Three Parquet files registered as SQL tables, backed by S3, queryable through a standard <code class="language-plaintext highlighter-rouge">DbConnection</code>. The <code class="language-plaintext highlighter-rouge">AsConnection()</code> extension method wraps the session context in an ADO.NET connection that Dapper knows how to work with.</p> <h2 id="defining-models">Defining Models</h2> <p>Dapper maps query results to C# types by matching column aliases to property names. Define records for each query shape:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">record</span> <span class="nc">UtilityCapacity</span><span class="p">(</span>
    <span class="kt">string</span> <span class="n">UtilityName</span><span class="p">,</span>
    <span class="kt">string</span><span class="p">?</span> <span class="n">State</span><span class="p">,</span>
    <span class="kt">long</span> <span class="n">PlantCount</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">TotalCapacityMw</span><span class="p">);</span>

<span class="k">public</span> <span class="k">record</span> <span class="nc">StateEnergyMix</span><span class="p">(</span>
    <span class="kt">string</span> <span class="n">State</span><span class="p">,</span>
    <span class="kt">string</span> <span class="n">FuelType</span><span class="p">,</span>
    <span class="kt">long</span> <span class="n">GeneratorCount</span><span class="p">,</span>
    <span class="kt">double</span> <span class="n">TotalCapacityMw</span><span class="p">,</span>
    <span class="kt">double</span><span class="p">?</span> <span class="n">AvgCapacityFactor</span><span class="p">);</span>

<span class="k">public</span> <span class="k">record</span> <span class="nc">TopGenerator</span><span class="p">(</span>
    <span class="kt">string</span> <span class="n">PlantName</span><span class="p">,</span>
    <span class="kt">string</span><span class="p">?</span> <span class="n">City</span><span class="p">,</span>
    <span class="kt">string</span><span class="p">?</span> <span class="n">State</span><span class="p">,</span>
    <span class="kt">string</span> <span class="n">UtilityName</span><span class="p">,</span>
    <span class="kt">string</span><span class="p">?</span> <span class="n">Technology</span><span class="p">,</span>
    <span class="kt">float</span> <span class="n">CapacityMw</span><span class="p">,</span>
    <span class="kt">double</span><span class="p">?</span> <span class="n">NetGenerationMwh</span><span class="p">,</span>
    <span class="kt">double</span><span class="p">?</span> <span class="n">CapacityFactor</span><span class="p">);</span>
</code></pre></div></div> <h2 id="querying-with-dapper">Querying with Dapper</h2> <h3 id="which-utilities-have-the-most-generation-capacity">Which utilities have the most generation capacity?</h3> <p>A three-way JOIN across all three tables to rank utilities by total installed capacity:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">topUtilities</span> <span class="p">=</span> <span class="k">await</span> <span class="n">connection</span><span class="p">.</span><span class="n">QueryAsync</span><span class="p">&lt;</span><span class="n">UtilityCapacity</span><span class="p">&gt;(</span><span class="s">"""
</span>    <span class="n">SELECT</span>
        <span class="n">u</span><span class="p">.</span><span class="n">utility_name_eia</span> <span class="n">AS</span> <span class="n">UtilityName</span><span class="p">,</span>
        <span class="n">u</span><span class="p">.</span><span class="n">state</span> <span class="n">AS</span> <span class="n">State</span><span class="p">,</span>
        <span class="nf">COUNT</span><span class="p">(</span><span class="n">DISTINCT</span> <span class="n">p</span><span class="p">.</span><span class="n">plant_id_eia</span><span class="p">)</span> <span class="n">AS</span> <span class="n">PlantCount</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">CAST</span><span class="p">(</span><span class="nf">SUM</span><span class="p">(</span><span class="n">g</span><span class="p">.</span><span class="n">capacity_mw</span><span class="p">)</span> <span class="n">AS</span> <span class="n">DOUBLE</span><span class="p">),</span> <span class="m">1</span><span class="p">)</span> <span class="n">AS</span> <span class="n">TotalCapacityMw</span>
    <span class="n">FROM</span> <span class="n">generators</span> <span class="n">g</span>
        <span class="n">JOIN</span> <span class="n">plants</span> <span class="n">p</span>
            <span class="n">ON</span> <span class="n">g</span><span class="p">.</span><span class="n">plant_id_eia</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">plant_id_eia</span>
            <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">report_date</span>
        <span class="n">JOIN</span> <span class="n">utilities</span> <span class="n">u</span>
            <span class="n">ON</span> <span class="n">p</span><span class="p">.</span><span class="n">utility_id_eia</span> <span class="p">=</span> <span class="n">u</span><span class="p">.</span><span class="n">utility_id_eia</span>
            <span class="n">AND</span> <span class="n">p</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="n">u</span><span class="p">.</span><span class="n">report_date</span>
    <span class="n">WHERE</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="err">'</span><span class="m">2026</span><span class="p">-</span><span class="m">01</span><span class="p">-</span><span class="m">01</span><span class="err">'</span>
        <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">operational_status</span> <span class="p">=</span> <span class="err">'</span><span class="n">existing</span><span class="err">'</span>
    <span class="n">GROUP</span> <span class="n">BY</span> <span class="n">u</span><span class="p">.</span><span class="n">utility_name_eia</span><span class="p">,</span> <span class="n">u</span><span class="p">.</span><span class="n">state</span>
    <span class="n">ORDER</span> <span class="n">BY</span> <span class="n">TotalCapacityMw</span> <span class="n">DESC</span>
    <span class="n">LIMIT</span> <span class="m">15</span>
    <span class="s">""");
</span>
<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">utility</span> <span class="k">in</span> <span class="n">topUtilities</span><span class="p">)</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">utility</span><span class="p">.</span><span class="n">UtilityName</span><span class="p">}</span><span class="s"> (</span><span class="p">{</span><span class="n">utility</span><span class="p">.</span><span class="n">State</span><span class="p">}</span><span class="s">): </span><span class="p">{</span><span class="n">utility</span><span class="p">.</span><span class="n">TotalCapacityMw</span><span class="p">:</span><span class="n">N1</span><span class="p">}</span><span class="s"> MW across </span><span class="p">{</span><span class="n">utility</span><span class="p">.</span><span class="n">PlantCount</span><span class="p">}</span><span class="s"> plants"</span><span class="p">);</span>
</code></pre></div></div> <p>This is standard Dapper — <code class="language-plaintext highlighter-rouge">QueryAsync&lt;T&gt;</code> with a SQL string. The SQL runs inside DataFusion’s query engine, reading Parquet data from S3 on the fly. Dapper sees a <code class="language-plaintext highlighter-rouge">DbDataReader</code> and maps columns to record properties by name. The query joins across three files totaling 16 MB, filters to currently operating generators as of the 2026 reporting year, aggregates by utility, and returns the top 15.</p> <h3 id="what-does-the-energy-mix-look-like">What does the energy mix look like?</h3> <p>Join generators with plants to analyze installed capacity by fuel type and state:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">energyMix</span> <span class="p">=</span> <span class="k">await</span> <span class="n">connection</span><span class="p">.</span><span class="n">QueryAsync</span><span class="p">&lt;</span><span class="n">StateEnergyMix</span><span class="p">&gt;(</span><span class="s">"""
</span>    <span class="n">SELECT</span>
        <span class="n">p</span><span class="p">.</span><span class="n">state</span> <span class="n">AS</span> <span class="n">State</span><span class="p">,</span>
        <span class="nf">CAST</span><span class="p">(</span><span class="n">g</span><span class="p">.</span><span class="n">fuel_type_code_pudl</span> <span class="n">AS</span> <span class="n">VARCHAR</span><span class="p">)</span> <span class="n">AS</span> <span class="n">FuelType</span><span class="p">,</span>
        <span class="nf">COUNT</span><span class="p">(*)</span> <span class="n">AS</span> <span class="n">GeneratorCount</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">CAST</span><span class="p">(</span><span class="nf">SUM</span><span class="p">(</span><span class="n">g</span><span class="p">.</span><span class="n">capacity_mw</span><span class="p">)</span> <span class="n">AS</span> <span class="n">DOUBLE</span><span class="p">),</span> <span class="m">1</span><span class="p">)</span> <span class="n">AS</span> <span class="n">TotalCapacityMw</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">CAST</span><span class="p">(</span><span class="nf">AVG</span><span class="p">(</span><span class="n">g</span><span class="p">.</span><span class="n">capacity_factor</span><span class="p">)</span> <span class="n">AS</span> <span class="n">DOUBLE</span><span class="p">),</span> <span class="m">3</span><span class="p">)</span> <span class="n">AS</span> <span class="n">AvgCapacityFactor</span>
    <span class="n">FROM</span> <span class="n">generators</span> <span class="n">g</span>
        <span class="n">JOIN</span> <span class="n">plants</span> <span class="n">p</span>
            <span class="n">ON</span> <span class="n">g</span><span class="p">.</span><span class="n">plant_id_eia</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">plant_id_eia</span>
            <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">report_date</span>
    <span class="n">WHERE</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="err">'</span><span class="m">2026</span><span class="p">-</span><span class="m">01</span><span class="p">-</span><span class="m">01</span><span class="err">'</span>
        <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">operational_status</span> <span class="p">=</span> <span class="err">'</span><span class="n">existing</span><span class="err">'</span>
        <span class="n">AND</span> <span class="n">p</span><span class="p">.</span><span class="n">state</span> <span class="n">IS</span> <span class="n">NOT</span> <span class="n">NULL</span>
    <span class="n">GROUP</span> <span class="n">BY</span> <span class="n">p</span><span class="p">.</span><span class="n">state</span><span class="p">,</span> <span class="n">g</span><span class="p">.</span><span class="n">fuel_type_code_pudl</span>
    <span class="n">ORDER</span> <span class="n">BY</span> <span class="n">TotalCapacityMw</span> <span class="n">DESC</span>
    <span class="n">LIMIT</span> <span class="m">20</span>
    <span class="s">""");
</span>
<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">row</span> <span class="k">in</span> <span class="n">energyMix</span><span class="p">)</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">row</span><span class="p">.</span><span class="n">State</span><span class="p">}</span><span class="s"> - </span><span class="p">{</span><span class="n">row</span><span class="p">.</span><span class="n">FuelType</span><span class="p">}</span><span class="s">: </span><span class="p">{</span><span class="n">row</span><span class="p">.</span><span class="n">TotalCapacityMw</span><span class="p">:</span><span class="n">N1</span><span class="p">}</span><span class="s"> MW (</span><span class="p">{</span><span class="n">row</span><span class="p">.</span><span class="n">GeneratorCount</span><span class="p">}</span><span class="s"> generators)"</span><span class="p">);</span>
</code></pre></div></div> <p>Notice the <code class="language-plaintext highlighter-rouge">CAST(g.fuel_type_code_pudl AS VARCHAR)</code> — some Parquet columns use Arrow’s dictionary encoding for efficient storage of repeated strings. The ADO.NET provider doesn’t expose dictionary types directly, so casting to <code class="language-plaintext highlighter-rouge">VARCHAR</code> in the query is the simplest way to handle them. This shows where capacity is concentrated — which states have the most gas, coal, wind, or solar, and how their capacity factors compare.</p> <h3 id="the-largest-generators-in-the-country">The largest generators in the country</h3> <p>A three-way JOIN to find the biggest individual generators with full context — plant name, location, operator, and technology:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">largestGenerators</span> <span class="p">=</span> <span class="k">await</span> <span class="n">connection</span><span class="p">.</span><span class="n">QueryAsync</span><span class="p">&lt;</span><span class="n">TopGenerator</span><span class="p">&gt;(</span><span class="s">"""
</span>    <span class="n">SELECT</span>
        <span class="n">p</span><span class="p">.</span><span class="n">plant_name_eia</span> <span class="n">AS</span> <span class="n">PlantName</span><span class="p">,</span>
        <span class="n">p</span><span class="p">.</span><span class="n">city</span> <span class="n">AS</span> <span class="n">City</span><span class="p">,</span>
        <span class="n">p</span><span class="p">.</span><span class="n">state</span> <span class="n">AS</span> <span class="n">State</span><span class="p">,</span>
        <span class="n">u</span><span class="p">.</span><span class="n">utility_name_eia</span> <span class="n">AS</span> <span class="n">UtilityName</span><span class="p">,</span>
        <span class="n">g</span><span class="p">.</span><span class="n">technology_description</span> <span class="n">AS</span> <span class="n">Technology</span><span class="p">,</span>
        <span class="n">g</span><span class="p">.</span><span class="n">capacity_mw</span> <span class="n">AS</span> <span class="n">CapacityMw</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">CAST</span><span class="p">(</span><span class="n">g</span><span class="p">.</span><span class="n">net_generation_mwh</span> <span class="n">AS</span> <span class="n">DOUBLE</span><span class="p">),</span> <span class="m">0</span><span class="p">)</span> <span class="n">AS</span> <span class="n">NetGenerationMwh</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">CAST</span><span class="p">(</span><span class="n">g</span><span class="p">.</span><span class="n">capacity_factor</span> <span class="n">AS</span> <span class="n">DOUBLE</span><span class="p">),</span> <span class="m">3</span><span class="p">)</span> <span class="n">AS</span> <span class="n">CapacityFactor</span>
    <span class="n">FROM</span> <span class="n">generators</span> <span class="n">g</span>
        <span class="n">JOIN</span> <span class="n">plants</span> <span class="n">p</span>
            <span class="n">ON</span> <span class="n">g</span><span class="p">.</span><span class="n">plant_id_eia</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">plant_id_eia</span>
            <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">report_date</span>
        <span class="n">JOIN</span> <span class="n">utilities</span> <span class="n">u</span>
            <span class="n">ON</span> <span class="n">p</span><span class="p">.</span><span class="n">utility_id_eia</span> <span class="p">=</span> <span class="n">u</span><span class="p">.</span><span class="n">utility_id_eia</span>
            <span class="n">AND</span> <span class="n">p</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="n">u</span><span class="p">.</span><span class="n">report_date</span>
    <span class="n">WHERE</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="err">'</span><span class="m">2026</span><span class="p">-</span><span class="m">01</span><span class="p">-</span><span class="m">01</span><span class="err">'</span>
        <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">operational_status</span> <span class="p">=</span> <span class="err">'</span><span class="n">existing</span><span class="err">'</span>
        <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">capacity_mw</span> <span class="n">IS</span> <span class="n">NOT</span> <span class="n">NULL</span>
    <span class="n">ORDER</span> <span class="n">BY</span> <span class="n">g</span><span class="p">.</span><span class="n">capacity_mw</span> <span class="n">DESC</span>
    <span class="n">LIMIT</span> <span class="m">10</span>
    <span class="s">""");
</span>
<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">gen</span> <span class="k">in</span> <span class="n">largestGenerators</span><span class="p">)</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">gen</span><span class="p">.</span><span class="n">PlantName</span><span class="p">}</span><span class="s"> (</span><span class="p">{</span><span class="n">gen</span><span class="p">.</span><span class="n">State</span><span class="p">}</span><span class="s">): </span><span class="p">{</span><span class="n">gen</span><span class="p">.</span><span class="n">CapacityMw</span><span class="p">:</span><span class="n">N0</span><span class="p">}</span><span class="s"> MW - </span><span class="p">{</span><span class="n">gen</span><span class="p">.</span><span class="n">Technology</span><span class="p">}</span><span class="s"> [</span><span class="p">{</span><span class="n">gen</span><span class="p">.</span><span class="n">UtilityName</span><span class="p">}</span><span class="s">]"</span><span class="p">);</span>
</code></pre></div></div> <h2 id="parameterized-queries">Parameterized Queries</h2> <p>Dapper’s anonymous parameter objects work with DataFusionSharp. The ADO.NET provider translates <code class="language-plaintext highlighter-rouge">@param</code> syntax to DataFusion’s native <code class="language-plaintext highlighter-rouge">$param</code> format automatically:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">state</span> <span class="p">=</span> <span class="s">"TX"</span><span class="p">;</span>
<span class="kt">var</span> <span class="n">minCapacityMw</span> <span class="p">=</span> <span class="m">100f</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">texasPlants</span> <span class="p">=</span> <span class="k">await</span> <span class="n">connection</span><span class="p">.</span><span class="n">QueryAsync</span><span class="p">&lt;</span><span class="n">TopGenerator</span><span class="p">&gt;(</span><span class="s">"""
</span>    <span class="n">SELECT</span>
        <span class="n">p</span><span class="p">.</span><span class="n">plant_name_eia</span> <span class="n">AS</span> <span class="n">PlantName</span><span class="p">,</span>
        <span class="n">p</span><span class="p">.</span><span class="n">city</span> <span class="n">AS</span> <span class="n">City</span><span class="p">,</span>
        <span class="n">p</span><span class="p">.</span><span class="n">state</span> <span class="n">AS</span> <span class="n">State</span><span class="p">,</span>
        <span class="n">u</span><span class="p">.</span><span class="n">utility_name_eia</span> <span class="n">AS</span> <span class="n">UtilityName</span><span class="p">,</span>
        <span class="n">g</span><span class="p">.</span><span class="n">technology_description</span> <span class="n">AS</span> <span class="n">Technology</span><span class="p">,</span>
        <span class="n">g</span><span class="p">.</span><span class="n">capacity_mw</span> <span class="n">AS</span> <span class="n">CapacityMw</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">CAST</span><span class="p">(</span><span class="n">g</span><span class="p">.</span><span class="n">net_generation_mwh</span> <span class="n">AS</span> <span class="n">DOUBLE</span><span class="p">),</span> <span class="m">0</span><span class="p">)</span> <span class="n">AS</span> <span class="n">NetGenerationMwh</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">CAST</span><span class="p">(</span><span class="n">g</span><span class="p">.</span><span class="n">capacity_factor</span> <span class="n">AS</span> <span class="n">DOUBLE</span><span class="p">),</span> <span class="m">3</span><span class="p">)</span> <span class="n">AS</span> <span class="n">CapacityFactor</span>
    <span class="n">FROM</span> <span class="n">generators</span> <span class="n">g</span>
        <span class="n">JOIN</span> <span class="n">plants</span> <span class="n">p</span>
            <span class="n">ON</span> <span class="n">g</span><span class="p">.</span><span class="n">plant_id_eia</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">plant_id_eia</span>
            <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">report_date</span>
        <span class="n">JOIN</span> <span class="n">utilities</span> <span class="n">u</span>
            <span class="n">ON</span> <span class="n">p</span><span class="p">.</span><span class="n">utility_id_eia</span> <span class="p">=</span> <span class="n">u</span><span class="p">.</span><span class="n">utility_id_eia</span>
            <span class="n">AND</span> <span class="n">p</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="n">u</span><span class="p">.</span><span class="n">report_date</span>
    <span class="n">WHERE</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="err">'</span><span class="m">2026</span><span class="p">-</span><span class="m">01</span><span class="p">-</span><span class="m">01</span><span class="err">'</span>
        <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">operational_status</span> <span class="p">=</span> <span class="err">'</span><span class="n">existing</span><span class="err">'</span>
        <span class="n">AND</span> <span class="n">p</span><span class="p">.</span><span class="n">state</span> <span class="p">=</span> <span class="n">@state</span>
        <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">capacity_mw</span> <span class="p">&gt;=</span> <span class="n">@minCapacityMw</span>
    <span class="n">ORDER</span> <span class="n">BY</span> <span class="n">g</span><span class="p">.</span><span class="n">capacity_mw</span> <span class="n">DESC</span>
    <span class="n">LIMIT</span> <span class="m">10</span>
    <span class="s">""",
</span>    <span class="k">new</span> <span class="p">{</span> <span class="n">state</span><span class="p">,</span> <span class="n">minCapacityMw</span> <span class="p">});</span>

<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">plant</span> <span class="k">in</span> <span class="n">texasPlants</span><span class="p">)</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">plant</span><span class="p">.</span><span class="n">PlantName</span><span class="p">}</span><span class="s">: </span><span class="p">{</span><span class="n">plant</span><span class="p">.</span><span class="n">CapacityMw</span><span class="p">:</span><span class="n">N0</span><span class="p">}</span><span class="s"> MW - </span><span class="p">{</span><span class="n">plant</span><span class="p">.</span><span class="n">Technology</span><span class="p">}</span><span class="s"> [</span><span class="p">{</span><span class="n">plant</span><span class="p">.</span><span class="n">UtilityName</span><span class="p">}</span><span class="s">]"</span><span class="p">);</span>
</code></pre></div></div> <p>The <code class="language-plaintext highlighter-rouge">@state</code> and <code class="language-plaintext highlighter-rouge">@minCapacityMw</code> parameters are passed as a standard Dapper anonymous object. Under the hood, <code class="language-plaintext highlighter-rouge">DataFusionSharpCommand</code> rewrites them to <code class="language-plaintext highlighter-rouge">$state</code> and <code class="language-plaintext highlighter-rouge">$minCapacityMw</code>, maps their .NET types to DataFusion scalar values, and injects them into the query plan.</p> <p>Scalar queries work the same way — <code class="language-plaintext highlighter-rouge">ExecuteScalarAsync</code> returns a single value:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">totalCapacity</span> <span class="p">=</span> <span class="k">await</span> <span class="n">connection</span><span class="p">.</span><span class="n">ExecuteScalarAsync</span><span class="p">&lt;</span><span class="kt">double</span><span class="p">&gt;(</span><span class="s">"""
</span>    <span class="n">SELECT</span> <span class="nf">ROUND</span><span class="p">(</span><span class="nf">CAST</span><span class="p">(</span><span class="nf">SUM</span><span class="p">(</span><span class="n">g</span><span class="p">.</span><span class="n">capacity_mw</span><span class="p">)</span> <span class="n">AS</span> <span class="n">DOUBLE</span><span class="p">),</span> <span class="m">1</span><span class="p">)</span>
    <span class="n">FROM</span> <span class="n">generators</span> <span class="n">g</span>
        <span class="n">JOIN</span> <span class="n">plants</span> <span class="n">p</span>
            <span class="n">ON</span> <span class="n">g</span><span class="p">.</span><span class="n">plant_id_eia</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">plant_id_eia</span>
            <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="n">p</span><span class="p">.</span><span class="n">report_date</span>
    <span class="n">WHERE</span> <span class="n">g</span><span class="p">.</span><span class="n">report_date</span> <span class="p">=</span> <span class="err">'</span><span class="m">2026</span><span class="p">-</span><span class="m">01</span><span class="p">-</span><span class="m">01</span><span class="err">'</span>
        <span class="n">AND</span> <span class="n">g</span><span class="p">.</span><span class="n">operational_status</span> <span class="p">=</span> <span class="err">'</span><span class="n">existing</span><span class="err">'</span>
        <span class="n">AND</span> <span class="n">p</span><span class="p">.</span><span class="n">state</span> <span class="p">=</span> <span class="n">@state</span>
    <span class="s">""",
</span>    <span class="k">new</span> <span class="p">{</span> <span class="n">state</span> <span class="p">=</span> <span class="s">"CA"</span> <span class="p">});</span>

<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"California total installed capacity: </span><span class="p">{</span><span class="n">totalCapacity</span><span class="p">:</span><span class="n">N1</span><span class="p">}</span><span class="s"> MW"</span><span class="p">);</span>
</code></pre></div></div> <hr/> <p>The complete example from this post is available in the <a href="https://github.com/nazarii-piontko/datafusion-sharp">DataFusionSharp repository</a> under <code class="language-plaintext highlighter-rouge">examples/QueryS3DataWithDapper</code>. The dataset is publicly available — no AWS credentials required. The PUDL project publishes cleaned EIA data at <code class="language-plaintext highlighter-rouge">s3://pudl.catalyst.coop/stable/</code> under a <a href="https://creativecommons.org/licenses/by/4.0/">CC-BY-4.0</a> license.</p> <p>If you’re already using Dapper with a database today, adding DataFusionSharp as a second data source is straightforward: the same <code class="language-plaintext highlighter-rouge">QueryAsync&lt;T&gt;</code> calls, the same parameter passing, the same result mapping — just pointed at files in S3 instead of rows in a table.</p> <hr/> <p>DataFusionSharp is on GitHub at <a href="https://github.com/nazarii-piontko/datafusion-sharp">github.com/nazarii-piontko/datafusion-sharp</a>. If you’re new to the library, the <a href="/2026/02/24/datafusion-sharp">introductory post</a> covers the fundamentals — runtime, sessions, and local file queries. The <a href="/2026/03/23/query-s3-parquet-dotnet-datafusion-sharp">S3 querying post</a> goes deeper on object store configuration, Hive partitioning, and Arrow batch processing. If you run into issues or have ideas for the API, open an issue or a pull request.</p>]]></content><author><name>Nazarii Piontko</name></author><category term="dotnet"/><category term="csharp"/><category term="datafusion"/><category term="s3"/><category term="aws"/><category term="parquet"/><category term="dapper"/><category term="datafusionsharp"/><category term="adonet"/><summary type="html"><![CDATA[Use Dapper — the same micro-ORM you use with PostgreSQL or SQL Server — to query Parquet files on S3. DataFusionSharp's ADO.NET provider turns Apache DataFusion into a standard DbConnection, so QueryAsync just works.]]></summary></entry><entry><title type="html">Query Parquet Files on S3 from .NET with DataFusionSharp</title><link href="https://www.npiontko.pro/2026/03/23/query-s3-parquet-dotnet-datafusion-sharp" rel="alternate" type="text/html" title="Query Parquet Files on S3 from .NET with DataFusionSharp"/><published>2026-03-23T00:00:00+00:00</published><updated>2026-03-23T00:00:00+00:00</updated><id>https://www.npiontko.pro/2026/03/23/query-s3-parquet-dotnet-datafusion-sharp</id><content type="html" xml:base="https://www.npiontko.pro/2026/03/23/query-s3-parquet-dotnet-datafusion-sharp"><![CDATA[<p>Sooner or later, every data workflow ends up with files in S3. A data pipeline writes Parquet output to a bucket. A vendor drops CSV exports into a shared prefix. A public dataset lives in an AWS open data registry. You need to explore it, query it, maybe join it with something else — and you’re working in .NET.</p> <p>The typical path forward is to leave .NET. Fire up a Jupyter notebook with PyArrow, spin up an Athena query, or download the files locally and process them with something else. If you’ve done this enough times, the friction becomes familiar: context-switching between languages, waiting for infrastructure, or managing local copies of data that already lives perfectly well in the cloud.</p> <p>When I built <a href="https://github.com/nazarii-piontko/datafusion-sharp">DataFusionSharp</a>, the first version only worked with local files — CSV, Parquet, and JSON on disk. That was useful, but it didn’t address the reality that most analytical data lives in object stores. The latest release changes that. DataFusionSharp now supports S3, Azure Blob Storage, Google Cloud Storage, and HTTP endpoints as data sources. Register a store, register a table, write SQL. The data stays where it is.</p> <p>This post walks through querying Parquet data in S3 from a .NET console application. The dataset is public, so everything here runs without AWS credentials.</p> <h2 id="connecting-to-s3">Connecting to S3</h2> <p>The pattern is two steps: register an object store for a URL scheme, then register tables using URLs instead of local paths. Here’s what connecting to a public S3 bucket looks like:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">var</span> <span class="n">runtime</span> <span class="p">=</span> <span class="n">DataFusionRuntime</span><span class="p">.</span><span class="nf">Create</span><span class="p">();</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">context</span> <span class="p">=</span> <span class="n">runtime</span><span class="p">.</span><span class="nf">CreateSessionContext</span><span class="p">();</span>

<span class="c1">// Register public S3 bucket (no credentials needed)</span>
<span class="n">context</span><span class="p">.</span><span class="nf">RegisterS3ObjectStore</span><span class="p">(</span><span class="s">"s3://arrow-datasets"</span><span class="p">,</span> <span class="k">new</span> <span class="n">S3ObjectStoreOptions</span>
<span class="p">{</span>
    <span class="n">BucketName</span> <span class="p">=</span> <span class="s">"arrow-datasets"</span><span class="p">,</span>
    <span class="n">Region</span> <span class="p">=</span> <span class="s">"us-east-1"</span><span class="p">,</span>
    <span class="n">SkipSignature</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>
<span class="p">});</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">SkipSignature = true</code> tells the client to skip request signing, which is how you access public buckets anonymously. For private buckets, you’d set <code class="language-plaintext highlighter-rouge">AccessKeyId</code> and <code class="language-plaintext highlighter-rouge">SecretAccessKey</code>, or provide a session <code class="language-plaintext highlighter-rouge">Token</code> for temporary credentials. If you’re running on EC2 or ECS with an IAM role, leave the credentials out entirely and the AWS credential chain handles it.</p> <p>The same pattern works for S3-compatible services like MinIO — just set the <code class="language-plaintext highlighter-rouge">Endpoint</code> property to your service URL and <code class="language-plaintext highlighter-rouge">AllowHttp = true</code> if you’re not using TLS.</p> <h2 id="hive-partitioning">Hive Partitioning</h2> <p>The dataset we’ll query is the diamonds dataset from the Apache Arrow test data registry. It’s stored as Parquet files using Hive-style partitioning, where directory names encode column values:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>s3://arrow-datasets/diamonds/
  cut=Fair/
    part-0.parquet
  cut=Good/
    part-0.parquet
  cut=Ideal/
    part-0.parquet
  cut=Premium/
    part-0.parquet
  cut=Very Good/
    part-0.parquet
</code></pre></div></div> <p>Each subdirectory contains rows for one value of the <code class="language-plaintext highlighter-rouge">cut</code> column. The column itself doesn’t exist inside the Parquet files — DataFusion reconstructs it from the directory structure. To use this, you declare partition columns when registering the table:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">RegisterParquetAsync</span><span class="p">(</span><span class="s">"diamonds"</span><span class="p">,</span> <span class="s">"s3://arrow-datasets/diamonds/"</span><span class="p">,</span>
    <span class="k">new</span> <span class="n">ParquetReadOptions</span>
    <span class="p">{</span>
        <span class="n">TablePartitionCols</span> <span class="p">=</span> <span class="p">[</span><span class="k">new</span> <span class="nf">PartitionColumn</span><span class="p">(</span><span class="s">"cut"</span><span class="p">,</span> <span class="n">StringType</span><span class="p">.</span><span class="n">Default</span><span class="p">)],</span>
        <span class="n">ParquetPruning</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>
    <span class="p">});</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">TablePartitionCols</code> tells DataFusion which directory levels map to which columns and their types. <code class="language-plaintext highlighter-rouge">ParquetPruning = true</code> enables predicate pushdown at the partition level: when your query filters on <code class="language-plaintext highlighter-rouge">cut</code>, DataFusion only reads the matching partition directories. A <code class="language-plaintext highlighter-rouge">WHERE cut = 'Ideal'</code> skips four out of five partitions entirely. For large datasets with many partitions, this is the difference between scanning everything and scanning almost nothing.</p> <h2 id="querying-the-data">Querying the Data</h2> <p>Once the table is registered, everything works exactly like querying local files. The SQL is standard, the results come back as Arrow batches, and you can use the same <code class="language-plaintext highlighter-rouge">DataFrame</code> API from my <a href="/2026/02/24/datafusion-sharp">previous post</a>.</p> <p>A useful first step with an unfamiliar dataset is inspecting its schema:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">df</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">SqlAsync</span><span class="p">(</span><span class="s">"SELECT * FROM diamonds LIMIT 0"</span><span class="p">))</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">schema</span> <span class="p">=</span> <span class="n">df</span><span class="p">.</span><span class="nf">GetSchema</span><span class="p">();</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">field</span> <span class="k">in</span> <span class="n">schema</span><span class="p">.</span><span class="n">FieldsList</span><span class="p">)</span>
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"  </span><span class="p">{</span><span class="n">field</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">: </span><span class="p">{</span><span class="n">field</span><span class="p">.</span><span class="n">DataType</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div> <p>The <code class="language-plaintext highlighter-rouge">LIMIT 0</code> trick returns no rows but still resolves the full schema, including the <code class="language-plaintext highlighter-rouge">cut</code> partition column that DataFusion injects. This is a fast way to understand what columns are available before writing any real queries.</p> <p>With the schema in hand, we can run aggregations. Here’s a breakdown of price statistics by cut:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">df</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">SqlAsync</span><span class="p">(</span><span class="s">"""
</span>    <span class="n">SELECT</span>
        <span class="n">cut</span><span class="p">,</span>
        <span class="nf">count</span><span class="p">(*)</span> <span class="n">AS</span> <span class="n">cnt</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">avg</span><span class="p">(</span><span class="n">price</span><span class="p">),</span> <span class="m">2</span><span class="p">)</span> <span class="n">AS</span> <span class="n">avg_price</span><span class="p">,</span>
        <span class="nf">min</span><span class="p">(</span><span class="n">price</span><span class="p">)</span> <span class="n">AS</span> <span class="n">min_price</span><span class="p">,</span>
        <span class="nf">max</span><span class="p">(</span><span class="n">price</span><span class="p">)</span> <span class="n">AS</span> <span class="n">max_price</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">stddev</span><span class="p">(</span><span class="n">price</span><span class="p">),</span> <span class="m">2</span><span class="p">)</span> <span class="n">AS</span> <span class="n">price_stddev</span>
    <span class="n">FROM</span> <span class="n">diamonds</span>
    <span class="n">GROUP</span> <span class="n">BY</span> <span class="n">cut</span>
    <span class="n">ORDER</span> <span class="n">BY</span> <span class="n">avg_price</span> <span class="n">DESC</span>
    <span class="s">"""))
</span>    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="k">await</span> <span class="n">df</span><span class="p">.</span><span class="nf">ToStringAsync</span><span class="p">());</span>
</code></pre></div></div> <p>DataFusionSharp also supports parameterized queries. Parameters are passed as a list of name-value tuples, and DataFusion substitutes them before execution:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">df</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">SqlAsync</span><span class="p">(</span><span class="s">"""
</span>    <span class="n">SELECT</span> <span class="n">carat</span><span class="p">,</span> <span class="n">color</span><span class="p">,</span> <span class="n">clarity</span><span class="p">,</span> <span class="n">price</span>
    <span class="n">FROM</span> <span class="n">diamonds</span>
    <span class="n">WHERE</span> <span class="n">cut</span> <span class="p">=</span> <span class="err">$</span><span class="n">cut</span>
      <span class="n">AND</span> <span class="n">carat</span> <span class="p">&gt;=</span> <span class="err">$</span><span class="n">min_carat</span>
    <span class="n">ORDER</span> <span class="n">BY</span> <span class="n">price</span> <span class="n">DESC</span>
    <span class="n">LIMIT</span> <span class="m">10</span>
    <span class="s">""",
</span>    <span class="p">[(</span><span class="s">"cut"</span><span class="p">,</span> <span class="s">"Ideal"</span><span class="p">),</span> <span class="p">(</span><span class="s">"min_carat"</span><span class="p">,</span> <span class="m">2.0</span><span class="p">)]))</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="k">await</span> <span class="n">df</span><span class="p">.</span><span class="nf">ToStringAsync</span><span class="p">());</span>
</code></pre></div></div> <p>Notice that the <code class="language-plaintext highlighter-rouge">$cut</code> parameter filters on the partition column. Because partition pruning is enabled, DataFusion resolves the parameter value before planning the scan and only reads the <code class="language-plaintext highlighter-rouge">cut=Ideal/</code> directory. The other partitions are never touched.</p> <p>Query results can also be written back to files. If you want to materialize an aggregation as a local Parquet file:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">df</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">SqlAsync</span><span class="p">(</span><span class="s">"""
</span>    <span class="n">SELECT</span>
        <span class="n">cut</span><span class="p">,</span>
        <span class="n">color</span><span class="p">,</span>
        <span class="nf">count</span><span class="p">(*)</span> <span class="n">AS</span> <span class="n">cnt</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">avg</span><span class="p">(</span><span class="n">price</span><span class="p">),</span> <span class="m">2</span><span class="p">)</span> <span class="n">AS</span> <span class="n">avg_price</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">avg</span><span class="p">(</span><span class="n">carat</span><span class="p">),</span> <span class="m">3</span><span class="p">)</span> <span class="n">AS</span> <span class="n">avg_carat</span>
    <span class="n">FROM</span> <span class="n">diamonds</span>
    <span class="n">GROUP</span> <span class="n">BY</span> <span class="n">cut</span><span class="p">,</span> <span class="n">color</span>
    <span class="n">ORDER</span> <span class="n">BY</span> <span class="n">cut</span><span class="p">,</span> <span class="n">color</span>
    <span class="s">"""))
</span><span class="p">{</span>
    <span class="kt">var</span> <span class="n">outputPath</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="nf">Combine</span><span class="p">(</span><span class="n">Directory</span><span class="p">.</span><span class="nf">GetCurrentDirectory</span><span class="p">(),</span> <span class="s">"output"</span><span class="p">,</span> <span class="s">"diamond_summary.parquet"</span><span class="p">);</span>
    <span class="n">Directory</span><span class="p">.</span><span class="nf">CreateDirectory</span><span class="p">(</span><span class="n">Path</span><span class="p">.</span><span class="nf">GetDirectoryName</span><span class="p">(</span><span class="n">outputPath</span><span class="p">)!);</span>
    <span class="k">await</span> <span class="n">df</span><span class="p">.</span><span class="nf">WriteParquetAsync</span><span class="p">(</span><span class="n">outputPath</span><span class="p">);</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"Written to: </span><span class="p">{</span><span class="n">outputPath</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div> <p>This reads from S3, aggregates in-process, and writes the result locally — a one-way ETL step in a dozen lines.</p> <h2 id="working-with-arrow-batches">Working with Arrow Batches</h2> <p>When you need to process results programmatically rather than printing them, <code class="language-plaintext highlighter-rouge">CollectAsync</code> gives you Apache Arrow <code class="language-plaintext highlighter-rouge">RecordBatch</code> objects with typed column arrays:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="p">(</span><span class="kt">var</span> <span class="n">df</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">SqlAsync</span><span class="p">(</span><span class="s">"""
</span>    <span class="n">SELECT</span>
        <span class="n">carat</span><span class="p">,</span>
        <span class="n">cut</span><span class="p">,</span>
        <span class="n">color</span><span class="p">,</span>
        <span class="n">clarity</span><span class="p">,</span>
        <span class="n">price</span><span class="p">,</span>
        <span class="nf">ROUND</span><span class="p">(</span><span class="nf">CAST</span><span class="p">(</span><span class="n">price</span> <span class="n">AS</span> <span class="n">DOUBLE</span><span class="p">)</span> <span class="p">/</span> <span class="n">carat</span><span class="p">,</span> <span class="m">2</span><span class="p">)</span> <span class="n">AS</span> <span class="n">price_per_carat</span>
    <span class="n">FROM</span> <span class="n">diamonds</span>
    <span class="n">WHERE</span> <span class="n">carat</span> <span class="p">&gt;</span> <span class="m">0.5</span>
    <span class="n">ORDER</span> <span class="n">BY</span> <span class="n">price_per_carat</span> <span class="n">ASC</span>
    <span class="n">LIMIT</span> <span class="m">5</span>
    <span class="s">"""))
</span><span class="p">{</span>
    <span class="k">using</span> <span class="nn">var</span> <span class="n">collected</span> <span class="p">=</span> <span class="k">await</span> <span class="n">df</span><span class="p">.</span><span class="nf">CollectAsync</span><span class="p">();</span>
    <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">batch</span> <span class="k">in</span> <span class="n">collected</span><span class="p">.</span><span class="n">Batches</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">for</span> <span class="p">(</span><span class="kt">var</span> <span class="n">r</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">r</span> <span class="p">&lt;</span> <span class="n">batch</span><span class="p">.</span><span class="n">Length</span><span class="p">;</span> <span class="n">r</span><span class="p">++)</span>
        <span class="p">{</span>
            <span class="k">for</span> <span class="p">(</span><span class="kt">var</span> <span class="n">c</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">c</span> <span class="p">&lt;</span> <span class="n">batch</span><span class="p">.</span><span class="n">ColumnCount</span><span class="p">;</span> <span class="n">c</span><span class="p">++)</span>
            <span class="p">{</span>
                <span class="kt">var</span> <span class="n">v</span> <span class="p">=</span> <span class="n">batch</span><span class="p">.</span><span class="nf">Column</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="k">switch</span>
                <span class="p">{</span>
                    <span class="n">StringArray</span> <span class="n">a</span> <span class="p">=&gt;</span> <span class="p">(</span><span class="kt">object</span><span class="p">?)</span><span class="n">a</span><span class="p">.</span><span class="nf">GetString</span><span class="p">(</span><span class="n">r</span><span class="p">),</span>
                    <span class="n">DoubleArray</span> <span class="n">a</span> <span class="p">=&gt;</span> <span class="n">a</span><span class="p">.</span><span class="nf">GetValue</span><span class="p">(</span><span class="n">r</span><span class="p">),</span>
                    <span class="n">Int64Array</span> <span class="n">a</span> <span class="p">=&gt;</span> <span class="n">a</span><span class="p">.</span><span class="nf">GetValue</span><span class="p">(</span><span class="n">r</span><span class="p">),</span>
                    <span class="n">FloatArray</span> <span class="n">a</span> <span class="p">=&gt;</span> <span class="n">a</span><span class="p">.</span><span class="nf">GetValue</span><span class="p">(</span><span class="n">r</span><span class="p">),</span>
                    <span class="n">_</span> <span class="p">=&gt;</span> <span class="n">batch</span><span class="p">.</span><span class="nf">Column</span><span class="p">(</span><span class="n">c</span><span class="p">).</span><span class="nf">GetType</span><span class="p">().</span><span class="n">Name</span>
                <span class="p">};</span>
                <span class="n">Console</span><span class="p">.</span><span class="nf">Write</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">v</span><span class="p">}</span><span class="s">\t"</span><span class="p">);</span>
            <span class="p">}</span>
            <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">();</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>These are the same Arrow <code class="language-plaintext highlighter-rouge">RecordBatch</code> objects you’d get from querying local files. The <a href="https://www.nuget.org/packages/Apache.Arrow">Apache.Arrow</a> NuGet package gives you typed arrays (<code class="language-plaintext highlighter-rouge">StringArray</code>, <code class="language-plaintext highlighter-rouge">DoubleArray</code>, <code class="language-plaintext highlighter-rouge">Int64Array</code>, etc.), schema introspection, and interoperability with other Arrow-compatible tools in the .NET ecosystem.</p> <h2 id="beyond-s3">Beyond S3</h2> <p>The same object store pattern works for other cloud storage backends. Azure Blob Storage uses the <code class="language-plaintext highlighter-rouge">az://</code> scheme:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">context</span><span class="p">.</span><span class="nf">RegisterAzureBlobStorage</span><span class="p">(</span><span class="s">"az://my-container"</span><span class="p">,</span> <span class="k">new</span> <span class="n">AzureBlobStorageOptions</span>
<span class="p">{</span>
    <span class="n">ContainerName</span> <span class="p">=</span> <span class="s">"my-container"</span><span class="p">,</span>
    <span class="n">AccountName</span> <span class="p">=</span> <span class="s">"myaccount"</span><span class="p">,</span>
    <span class="n">AccessKey</span> <span class="p">=</span> <span class="s">"base64key..."</span><span class="p">,</span>
<span class="p">});</span>
</code></pre></div></div> <p>Google Cloud Storage uses <code class="language-plaintext highlighter-rouge">gs://</code>:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">context</span><span class="p">.</span><span class="nf">RegisterGoogleCloudStorage</span><span class="p">(</span><span class="s">"gs://my-bucket"</span><span class="p">,</span> <span class="k">new</span> <span class="n">GoogleCloudStorageOptions</span>
<span class="p">{</span>
    <span class="n">BucketName</span> <span class="p">=</span> <span class="s">"my-bucket"</span><span class="p">,</span>
    <span class="n">CredentialsPath</span> <span class="p">=</span> <span class="s">"/path/to/service-account.json"</span><span class="p">,</span>
<span class="p">});</span>
</code></pre></div></div> <p>Each backend has its own options class with the authentication methods you’d expect: access keys, SAS tokens, service principals for Azure; service account credentials for GCS. HTTP endpoints are also supported for read-only access to data served over plain HTTPS.</p> <p>The query code doesn’t change — once a table is registered, SQL works the same regardless of where the data lives. This is one of the things I appreciate about DataFusion’s architecture: the storage layer is fully decoupled from the query engine.</p> <h2 id="getting-started">Getting Started</h2> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package DataFusionSharp
</code></pre></div></div> <p>The complete example from this post is available in the <a href="https://github.com/nazarii-piontko/datafusion-sharp">DataFusionSharp repository</a> under <code class="language-plaintext highlighter-rouge">examples/QueryS3Data</code>. The public <code class="language-plaintext highlighter-rouge">arrow-datasets</code> bucket needs no AWS credentials, so you can run it as-is.</p> <hr/> <p>DataFusionSharp is on GitHub at <a href="https://github.com/nazarii-piontko/datafusion-sharp">github.com/nazarii-piontko/datafusion-sharp</a>. If you’re new to the library, the <a href="/2026/02/24/datafusion-sharp">introductory post</a> covers the basics — runtime, sessions, and local file queries. If you run into issues or have ideas for the API, open an issue or a pull request.</p>]]></content><author><name>Nazarii Piontko</name></author><category term="dotnet"/><category term="csharp"/><category term="datafusion"/><category term="s3"/><category term="aws"/><category term="parquet"/><category term="arrow"/><category term="datafusionsharp"/><summary type="html"><![CDATA[Run SQL queries on Parquet files stored in Amazon S3 directly from C# — no Python, no Athena, no file downloads. DataFusionSharp brings Apache DataFusion's query engine to .NET with full support for S3, Hive partitioning, and partition pruning.]]></summary></entry><entry><title type="html">Introducing DataFusionSharp: Apache DataFusion for .NET</title><link href="https://www.npiontko.pro/2026/02/24/datafusion-sharp" rel="alternate" type="text/html" title="Introducing DataFusionSharp: Apache DataFusion for .NET"/><published>2026-02-24T00:00:00+00:00</published><updated>2026-02-24T00:00:00+00:00</updated><id>https://www.npiontko.pro/2026/02/24/datafusion-sharp</id><content type="html" xml:base="https://www.npiontko.pro/2026/02/24/datafusion-sharp"><![CDATA[<p>At some point, every .NET developer working with data hits a wall. Maybe you have a directory full of Parquet files that keeps growing, or a few CSV dumps from a data pipeline that you need to join and aggregate. You don’t want to spin up a database, you don’t want to write a Python script, and you don’t want to roll your own file parser. You just want to run a SQL query and get results.</p> <p>The options in the .NET ecosystem have historically been limited. You could use <code class="language-plaintext highlighter-rouge">System.Data</code> with a file-backed SQLite database, wire up DuckDB, or ship data over the network to an external query engine. None of these feel right as a first-class .NET solution. What’s missing is an idiomatic, embeddable, high-performance SQL query engine that works directly on columnar data — something .NET has never really had.</p> <p>That gap is what motivated me to build <strong>DataFusionSharp</strong>.</p> <p><img src="/assets/posts/datafusion-sharp/logo-wide.png" alt="DataFusionSharp logo"/></p> <h2 id="what-is-apache-datafusion">What Is Apache DataFusion?</h2> <p><a href="https://datafusion.apache.org/">Apache DataFusion</a> is a query engine written in Rust, built on top of <a href="https://arrow.apache.org/">Apache Arrow</a>. It’s designed for high-performance analytical workloads: think scanning large files, aggregating millions of rows, joining multiple tables — all processed in-process without a server.</p> <p>What makes DataFusion stand out is the combination of things it brings together:</p> <ul> <li><strong>Vectorized execution</strong> — it processes data in columnar batches using Apache Arrow’s in-memory format, which maps naturally to modern CPU cache behavior and SIMD instructions</li> <li><strong>SQL query optimizer</strong> — a full logical and physical query planner with rule-based and cost-based optimizations</li> <li><strong>Datasource abstraction</strong> — read from CSV, Parquet, JSON, or plug in a custom source</li> <li><strong>Async, parallel execution</strong> — built on Tokio, DataFusion can execute queries using a thread pool with true async I/O</li> </ul> <p>DataFusion has become the foundation for a number of serious data tools in the Rust ecosystem. The Python community got <a href="https://github.com/apache/datafusion-python">datafusion-python</a> — official Apache-maintained bindings. The Java community got <a href="https://github.com/datafusion-contrib/datafusion-java">datafusion-java</a>. The .NET community had nothing. Until now.</p> <h2 id="introducing-datafusionsharp">Introducing DataFusionSharp</h2> <p><a href="https://github.com/nazarii-piontko/datafusion-sharp">DataFusionSharp</a> is a .NET library that exposes Apache DataFusion through idiomatic C# APIs. The approach is straightforward: a thin Rust FFI layer bridges the managed and native worlds, and C# P/Invoke calls drive it from the .NET side. Results are exchanged using Apache Arrow’s C Data Interface, so you get native Arrow <code class="language-plaintext highlighter-rouge">RecordBatch</code> objects on the .NET side.</p> <p>The library is organized around three types that map directly to how DataFusion works:</p> <p><strong><code class="language-plaintext highlighter-rouge">DataFusionRuntime</code></strong> — wraps a Tokio async runtime and manages the native library’s lifecycle. Create one per application and keep it alive for the duration of your process. It owns the thread pool that executes your queries.</p> <p><strong><code class="language-plaintext highlighter-rouge">SessionContext</code></strong> — an isolated query execution environment. Register your data sources here and execute SQL. Multiple contexts can coexist on the same runtime, and each is independent: tables registered in one context are not visible in another. Create one per logical session or per query workflow.</p> <p><strong><code class="language-plaintext highlighter-rouge">DataFrame</code></strong> — a lazy handle to a query result. The actual computation happens when you call a terminal operation. Available terminal operations are:</p> <ul> <li><code class="language-plaintext highlighter-rouge">CollectAsync()</code> — execute the query and return all results as Arrow <code class="language-plaintext highlighter-rouge">RecordBatch</code> objects</li> <li><code class="language-plaintext highlighter-rouge">ExecuteStreamAsync()</code> — execute the query and stream results as an <code class="language-plaintext highlighter-rouge">IAsyncEnumerable&lt;RecordBatch&gt;</code></li> <li><code class="language-plaintext highlighter-rouge">CountAsync()</code> — count result rows without materializing data</li> <li><code class="language-plaintext highlighter-rouge">GetSchemaAsync()</code> — inspect the result schema before executing</li> <li><code class="language-plaintext highlighter-rouge">ShowAsync()</code> / <code class="language-plaintext highlighter-rouge">ToStringAsync()</code> — print results, useful during development</li> </ul> <p>The library ships prebuilt native binaries for Linux x64, Linux arm64, Windows x64, and macOS arm64, so you don’t need a Rust toolchain to use it. Add it with:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dotnet add package DataFusionSharp
</code></pre></div></div> <h2 id="a-quick-example">A Quick Example</h2> <p>Let’s say we have two CSV files: <code class="language-plaintext highlighter-rouge">customers.csv</code> with customer records and <code class="language-plaintext highlighter-rouge">orders.csv</code> with order data, and we want to find the total completed order value per customer. Here is the full program:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Apache.Arrow</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">DataFusionSharp</span><span class="p">;</span>

<span class="c1">// Create runtime — one per application, owns the Tokio thread pool</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">runtime</span> <span class="p">=</span> <span class="n">DataFusionRuntime</span><span class="p">.</span><span class="nf">Create</span><span class="p">();</span>

<span class="c1">// Create session — one per logical query workflow</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">context</span> <span class="p">=</span> <span class="n">runtime</span><span class="p">.</span><span class="nf">CreateSessionContext</span><span class="p">();</span>

<span class="c1">// Register CSV tables (Parquet and JSON work the same way)</span>
<span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">RegisterCsvAsync</span><span class="p">(</span><span class="s">"customers"</span><span class="p">,</span> <span class="s">"data/customers.csv"</span><span class="p">);</span>
<span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">RegisterCsvAsync</span><span class="p">(</span><span class="s">"orders"</span><span class="p">,</span> <span class="s">"data/orders.csv"</span><span class="p">);</span>

<span class="c1">// Execute SQL — returns a lazy DataFrame</span>
<span class="k">using</span> <span class="nn">var</span> <span class="n">df</span> <span class="p">=</span> <span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">SqlAsync</span><span class="p">(</span>
    <span class="s">"""
</span>    <span class="n">SELECT</span>
        <span class="n">c</span><span class="p">.</span><span class="n">customer_name</span><span class="p">,</span>
        <span class="nf">sum</span><span class="p">(</span><span class="n">o</span><span class="p">.</span><span class="n">order_amount</span><span class="p">)</span> <span class="n">AS</span> <span class="n">total_amount</span>
    <span class="n">FROM</span> <span class="n">orders</span> <span class="n">AS</span> <span class="n">o</span>
        <span class="n">JOIN</span> <span class="n">customers</span> <span class="n">AS</span> <span class="n">c</span> <span class="n">ON</span> <span class="n">o</span><span class="p">.</span><span class="n">customer_id</span> <span class="p">=</span> <span class="n">c</span><span class="p">.</span><span class="n">customer_id</span>
    <span class="n">WHERE</span> <span class="n">o</span><span class="p">.</span><span class="n">order_status</span> <span class="p">=</span> <span class="err">'</span><span class="n">Completed</span><span class="err">'</span>
    <span class="n">GROUP</span> <span class="n">BY</span> <span class="n">c</span><span class="p">.</span><span class="n">customer_name</span>
    <span class="n">ORDER</span> <span class="n">BY</span> <span class="n">c</span><span class="p">.</span><span class="n">customer_name</span>
    <span class="s">""");
</span>
<span class="c1">// Print a formatted table to console — handy for development</span>
<span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="k">await</span> <span class="n">df</span><span class="p">.</span><span class="nf">ToStringAsync</span><span class="p">());</span>

<span class="c1">// Inspect the result schema</span>
<span class="kt">var</span> <span class="n">schema</span> <span class="p">=</span> <span class="k">await</span> <span class="n">df</span><span class="p">.</span><span class="nf">GetSchemaAsync</span><span class="p">();</span>
<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">field</span> <span class="k">in</span> <span class="n">schema</span><span class="p">.</span><span class="n">FieldsList</span><span class="p">)</span>
    <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">field</span><span class="p">.</span><span class="n">Name</span><span class="p">}</span><span class="s">: </span><span class="p">{</span><span class="n">field</span><span class="p">.</span><span class="n">DataType</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
</code></pre></div></div> <p>Notice the structure: create runtime, create session, register sources, execute SQL, consume results. The SQL is standard — the same query would run unchanged in PostgreSQL or DuckDB.</p> <p>For use cases where you need to process rows as they come rather than loading everything into memory at once, <code class="language-plaintext highlighter-rouge">ExecuteStreamAsync</code> gives you an async enumerable of <code class="language-plaintext highlighter-rouge">RecordBatch</code> objects:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">var</span> <span class="n">stream</span> <span class="p">=</span> <span class="k">await</span> <span class="n">df</span><span class="p">.</span><span class="nf">ExecuteStreamAsync</span><span class="p">();</span>
<span class="k">await</span> <span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">batch</span> <span class="k">in</span> <span class="n">stream</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">var</span> <span class="n">row</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">row</span> <span class="p">&lt;</span> <span class="n">batch</span><span class="p">.</span><span class="n">Length</span><span class="p">;</span> <span class="n">row</span><span class="p">++)</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">name</span> <span class="p">=</span> <span class="p">((</span><span class="n">StringArray</span><span class="p">)</span><span class="n">batch</span><span class="p">.</span><span class="nf">Column</span><span class="p">(</span><span class="m">0</span><span class="p">)).</span><span class="nf">GetString</span><span class="p">(</span><span class="n">row</span><span class="p">);</span>
        <span class="kt">var</span> <span class="n">total</span> <span class="p">=</span> <span class="p">((</span><span class="n">Int64Array</span><span class="p">)</span><span class="n">batch</span><span class="p">.</span><span class="nf">Column</span><span class="p">(</span><span class="m">1</span><span class="p">)).</span><span class="nf">GetValue</span><span class="p">(</span><span class="n">row</span><span class="p">);</span>
        <span class="n">Console</span><span class="p">.</span><span class="nf">WriteLine</span><span class="p">(</span><span class="s">$"</span><span class="p">{</span><span class="n">name</span><span class="p">}</span><span class="s">: </span><span class="p">{</span><span class="n">total</span><span class="p">}</span><span class="s">"</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>If you prefer to collect all data at once and then process it in memory, <code class="language-plaintext highlighter-rouge">CollectAsync</code> returns a collection of batches:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">var</span> <span class="n">result</span> <span class="p">=</span> <span class="k">await</span> <span class="n">df</span><span class="p">.</span><span class="nf">CollectAsync</span><span class="p">();</span>
<span class="k">foreach</span> <span class="p">(</span><span class="kt">var</span> <span class="n">batch</span> <span class="k">in</span> <span class="n">result</span><span class="p">.</span><span class="n">Batches</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">// process batch...</span>
<span class="p">}</span>
</code></pre></div></div> <p>Both paths give you Apache Arrow <code class="language-plaintext highlighter-rouge">RecordBatch</code> objects. The Arrow ecosystem in .NET — via the <a href="https://www.nuget.org/packages/Apache.Arrow">Apache.Arrow</a> NuGet package — gives you typed arrays, schema introspection, and interoperability with other Arrow-compatible tools.</p> <p>For Parquet files, the registration is a single method swap:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">RegisterParquetAsync</span><span class="p">(</span><span class="s">"orders"</span><span class="p">,</span> <span class="s">"data/orders.parquet"</span><span class="p">);</span>
</code></pre></div></div> <p>The rest of the query code stays identical. Same for JSONL files:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="n">context</span><span class="p">.</span><span class="nf">RegisterJsonAsync</span><span class="p">(</span><span class="s">"orders"</span><span class="p">,</span> <span class="s">"data/orders.json"</span><span class="p">);</span>
</code></pre></div></div> <p>This is one of the things I appreciate about DataFusion’s design: the query layer is completely decoupled from the storage layer.</p> <h2 id="current-state">Current State</h2> <p>DataFusionSharp is early-stage software. Here is an honest picture of what works today and what doesn’t:</p> <table> <thead> <tr> <th>Component</th> <th>Feature</th> <th>Status</th> </tr> </thead> <tbody> <tr> <td>Runtime</td> <td>Create Tokio runtime, graceful shutdown</td> <td>✅</td> </tr> <tr> <td>Session</td> <td>Create session, execute SQL</td> <td>✅</td> </tr> <tr> <td>Data Sources</td> <td>CSV read/write</td> <td>✅ partial — basic options exposed</td> </tr> <tr> <td> </td> <td>Parquet read/write</td> <td>✅ partial — no options exposed yet</td> </tr> <tr> <td> </td> <td>JSONL read/write</td> <td>✅ partial — no options exposed yet</td> </tr> <tr> <td> </td> <td>In-memory tables</td> <td>❌ not yet</td> </tr> <tr> <td>DataFrame</td> <td>Count, schema, collect, stream, print</td> <td>✅</td> </tr> <tr> <td> </td> <td>Select, filter, join, aggregate operators</td> <td>❌ use SQL instead</td> </tr> <tr> <td> </td> <td>Write to file</td> <td>✅ partial</td> </tr> <tr> <td>Arrow</td> <td>Apache Arrow record batches</td> <td>✅</td> </tr> <tr> <td> </td> <td>Zero-copy data transfer</td> <td>✅</td> </tr> <tr> <td>Advanced</td> <td>UDF registration</td> <td>❌ not yet</td> </tr> <tr> <td> </td> <td>Catalog management</td> <td>❌ not yet</td> </tr> <tr> <td>Platforms</td> <td>Linux x64/arm64, Windows x64, macOS arm64</td> <td>✅</td> </tr> </tbody> </table> <p>The practical implication: if you want to query CSV, Parquet, or JSON files using SQL and consume the results as Arrow batches, that works today and works well. If you need to push in-memory Arrow data into DataFusion, or register custom functions, or manage catalogs — those are on the roadmap.</p> <p>Note that this is an independent community project. It is not affiliated with or endorsed by the Apache Software Foundation.</p> <h2 id="whats-next">What’s Next</h2> <p>The most impactful things on the near-term roadmap:</p> <ul> <li><strong>In-memory table registration</strong> — push an Arrow <code class="language-plaintext highlighter-rouge">RecordBatch</code> directly into a session context as a table, no files required</li> <li><strong>Catalog management</strong> — expose APIs to create and manage catalogs, schemas, and tables programmatically</li> <li><strong>UDF support</strong> — register C# functions that DataFusion can call during query execution</li> </ul> <p>If any of these are interesting to you, contributions are very welcome.</p> <hr/> <p>DataFusionSharp is on GitHub at <a href="https://github.com/nazarii-piontko/datafusion-sharp">github.com/nazarii-piontko/datafusion-sharp</a>. If you try it and run into something unexpected, open an issue. If you have ideas for the API design or want to contribute, pull requests are open.</p>]]></content><author><name>Nazarii Piontko</name></author><category term="dotnet"/><category term="csharp"/><category term="rust"/><category term="datafusion"/><summary type="html"><![CDATA[Apache DataFusion is one of the most capable query engines available today — but until now, .NET developers had no way to use it. DataFusionSharp bridges that gap with idiomatic C# bindings built on Rust FFI and Apache Arrow.]]></summary></entry><entry><title type="html">C++ SIMD Auto-Vectorization: Why Array Access Patterns Matter</title><link href="https://www.npiontko.pro/2026/01/24/simd-auto-vectorization" rel="alternate" type="text/html" title="C++ SIMD Auto-Vectorization: Why Array Access Patterns Matter"/><published>2026-01-24T00:00:00+00:00</published><updated>2026-01-24T00:00:00+00:00</updated><id>https://www.npiontko.pro/2026/01/24/simd-auto-vectorization</id><content type="html" xml:base="https://www.npiontko.pro/2026/01/24/simd-auto-vectorization"><![CDATA[<p>Recently I’ve been working on bitpacking code, and I discovered something interesting: seemingly minor changes in how we access arrays can lead to dramatic performance improvements, all thanks to the compiler’s ability to auto-vectorize our code using <a href="https://en.wikipedia.org/wiki/Single_instruction,_multiple_data">SIMD</a> instructions. Let me show you what I mean.</p> <p>For simplification, let’s assume we need to pack 1024 64-bit unsigned integer values using 8 bits each in C++. Bitpacking is a common operation in compression algorithms and data serialization, where we want to squeeze multiple small values into a single machine word to save space.</p> <h2 id="the-naive-approach">The Naive Approach</h2> <p>What most of us would write first is a straightforward nested loop that processes values one at a time, something like this (simplified, no error checking, no bounds checking):</p> <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">constexpr</span> <span class="kt">size_t</span> <span class="n">ARRAY_SIZE</span> <span class="o">=</span> <span class="mi">1024</span><span class="p">;</span> <span class="c1">// Total number of input values</span>

<span class="kt">void</span> <span class="nf">bitpack_naive</span><span class="p">(</span><span class="k">const</span> <span class="kt">uint64_t</span><span class="o">*</span> <span class="kr">__restrict</span> <span class="n">input</span><span class="p">,</span> <span class="kt">uint64_t</span><span class="o">*</span> <span class="kr">__restrict</span> <span class="n">output</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">size_t</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="n">j</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">ARRAY_SIZE</span><span class="p">;</span> <span class="p">)</span> <span class="p">{</span>
        <span class="kt">uint64_t</span> <span class="n">acc</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

        <span class="k">for</span> <span class="p">(</span><span class="kt">size_t</span> <span class="n">k</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="n">shift</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="mi">8</span><span class="p">;</span> <span class="o">++</span><span class="n">k</span><span class="p">,</span> <span class="o">++</span><span class="n">i</span><span class="p">,</span> <span class="n">shift</span> <span class="o">+=</span> <span class="mi">8</span><span class="p">)</span>
            <span class="n">acc</span> <span class="o">|=</span> <span class="p">(</span><span class="n">input</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="n">shift</span><span class="p">;</span>

        <span class="n">output</span><span class="p">[</span><span class="n">j</span><span class="o">++</span><span class="p">]</span> <span class="o">=</span> <span class="n">acc</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>This code reads 8 consecutive input values, masks each to 8 bits, shifts them into appropriate position in accumulator, and combines them into a single 64-bit output word using bitwise OR. It’s simple, clean, readable, and… surprisingly effective when compiled with <code class="language-plaintext highlighter-rouge">-O3</code> (<code class="language-plaintext highlighter-rouge">-O3</code> option enables aggressive optimizations, additionally <code class="language-plaintext highlighter-rouge">-msse4</code> for SSE or <code class="language-plaintext highlighter-rouge">-mavx2</code> for AVX2 can be used to enable specific SIMD instruction sets).</p> <p>Note: <a href="https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions">SSE</a> (Streaming SIMD Extensions) allows to process multiple data points in parallel using 128-bit registers. It uses <code class="language-plaintext highlighter-rouge">xmm</code> CPU registers and instructions like <code class="language-plaintext highlighter-rouge">pand</code>, <code class="language-plaintext highlighter-rouge">psllq</code>, or <code class="language-plaintext highlighter-rouge">por</code> to manipulate data in parallel. Another common SIMD instruction set is <a href="https://en.wikipedia.org/wiki/Advanced_Vector_Extensions">AVX</a>, which uses 256-bit <code class="language-plaintext highlighter-rouge">ymm</code> CPU registers.</p> <p>Looking at the generated assembly, we can see the compiler (<a href="https://clang.llvm.org/"><code class="language-plaintext highlighter-rouge">clang-21</code></a> in my case) has done something remarkable. Instead of the simple scalar operations we wrote, it’s generated SIMD code using SSE instructions. Here’s a snippet of the generated assembly:</p> <div class="language-nasm highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">; Load pre-computed mask constants into XMM registers</span>
<span class="nf">movdqa</span>  <span class="nv">.LCPI7_0</span><span class="p">(</span><span class="o">%</span><span class="nv">rip</span><span class="p">),</span> <span class="o">%</span><span class="nv">xmm0</span>       <span class="c1">; xmm0 = mask for bytes 0 and 8 [255,0,0,0,0,0,0,0,255,0,0,0,0,0,0,0]</span>
<span class="nf">movdqa</span>  <span class="nv">.LCPI7_1</span><span class="p">(</span><span class="o">%</span><span class="nv">rip</span><span class="p">),</span> <span class="o">%</span><span class="nv">xmm1</span>       <span class="c1">; xmm1 = mask for bytes 1 and 9 [0,255,0,0,0,0,0,0,0,255,0,0,0,0,0,0]</span>
<span class="c1">; ... (additional masks loaded for other byte positions)</span>

<span class="nl">.LBB7_1:</span>                            <span class="c1">; Main loop starts here</span>
  <span class="c1">; Load two 64-bit values from different locations into XMM registers</span>
  <span class="nf">movq</span>       <span class="mi">64</span><span class="p">(</span><span class="o">%</span><span class="nb">rdi</span><span class="p">,</span><span class="o">%</span><span class="nb">rax</span><span class="p">,</span><span class="mi">8</span><span class="p">),</span> <span class="o">%</span><span class="nv">xmm7</span> <span class="c1">; Load value at input index 8 (64-bit data at address 64 + base, 64 = skip 8 values * 8 bytes each, base = %rdi + %rax*8) into xmm7 - second value</span>
  <span class="nf">movq</span>       <span class="p">(</span><span class="o">%</span><span class="nb">rdi</span><span class="p">,</span><span class="o">%</span><span class="nb">rax</span><span class="p">,</span><span class="mi">8</span><span class="p">),</span> <span class="o">%</span><span class="nv">xmm8</span>   <span class="c1">; Load value at input index 0 (64-bit data at address 0 + base, base = %rdi + %rax*8) into xmm8 - first value</span>
  <span class="nf">punpcklqdq</span> <span class="o">%</span><span class="nv">xmm7</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm8</span>           <span class="c1">; Combine the two values into a single 128-bit register xmm8</span>
  <span class="nf">pand</span>       <span class="o">%</span><span class="nv">xmm0</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm8</span>           <span class="c1">; Apply mask to extract only the bytes we want, bitwise AND, result in xmm8</span>

  <span class="c1">; Repeat for next pair of values</span>
  <span class="nf">movq</span>       <span class="mi">72</span><span class="p">(</span><span class="o">%</span><span class="nb">rdi</span><span class="p">,</span><span class="o">%</span><span class="nb">rax</span><span class="p">,</span><span class="mi">8</span><span class="p">),</span> <span class="o">%</span><span class="nv">xmm7</span> <span class="c1">; Load value at input index 9 (64-bit data at address 72 + base, 72 = skip 9 values * 8 bytes each, base = %rdi + %rax*8) into xmm7</span>
  <span class="nf">movq</span>       <span class="mi">8</span><span class="p">(</span><span class="o">%</span><span class="nb">rdi</span><span class="p">,</span><span class="o">%</span><span class="nb">rax</span><span class="p">,</span><span class="mi">8</span><span class="p">),</span> <span class="o">%</span><span class="nv">xmm9</span>  <span class="c1">; Load value at input index 1 (64-bit data at address 8 + base, 8 = skip 1 value * 8 byte, base = %rdi + %rax*8) into xmm9</span>
  <span class="nf">punpcklqdq</span> <span class="o">%</span><span class="nv">xmm7</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm9</span>           <span class="c1">; Combine the two values into a single 128-bit register xmm9</span>
  <span class="nf">psllq</span>      <span class="kc">$</span><span class="mi">8</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm9</span>              <span class="c1">; Shift left by 8 bits to position the byte correctly, result in xmm9</span>
  <span class="nf">pand</span>       <span class="o">%</span><span class="nv">xmm1</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm9</span>           <span class="c1">; Apply mask to extract only the bytes we want, bitwise AND, result in xmm9</span>

  <span class="nf">por</span>        <span class="o">%</span><span class="nv">xmm8</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm9</span>           <span class="c1">; Combine with previous result using bitwise OR, result in xmm9</span>

  <span class="c1">; ... (similar operations for remaining bytes)</span>

  <span class="nf">movdqu</span>     <span class="o">%</span><span class="nv">xmm8</span><span class="p">,</span> <span class="p">(</span><span class="o">%</span><span class="nb">rsi</span><span class="p">,</span><span class="o">%</span><span class="nb">rax</span><span class="p">)</span>     <span class="c1">; Store the final packed result into output array</span>

  <span class="c1">; Loop counter increment and check</span>
  <span class="nf">addq</span>       <span class="kc">$</span><span class="mi">16</span><span class="p">,</span> <span class="o">%</span><span class="nb">rax</span>              <span class="c1">; Move base forward by 16</span>
  <span class="nf">cmpq</span>       <span class="kc">$</span><span class="mi">1024</span><span class="p">,</span> <span class="o">%</span><span class="nb">rax</span>            <span class="c1">; Compare base with 1024</span>
  <span class="nf">jne</span>        <span class="nv">.LBB7_1</span>                <span class="c1">; Jump back if not done</span>
</code></pre></div></div> <p>Note: The exact instruction mix, register allocation, and masks will vary by compiler version, target CPU, and flags. The snippets are representative of the optimization strategy rather than a byte-for-byte listing.</p> <p>The compiler has done two clever optimizations here:</p> <ol> <li> <p><strong>Loop unrolling</strong>. The compiler unrolls the outer loop by a factor of 2, processing two output words per iteration (one from input indices 0-7, another from 8-15). The next iteration processes input indices 16-23 and 24-31, and so on. The inner loop is effectively eliminated through vectorization. This reduces loop overhead and increases instruction-level parallelism.</p> </li> <li> <p><strong>Auto-vectorization</strong>. It’s using SIMD SSE instructions to process multiple values in parallel. The <a href="https://www.felixcloutier.com/x86/punpcklbw:punpcklwd:punpckldq:punpcklqdq"><code class="language-plaintext highlighter-rouge">punpcklqdq</code></a> instruction interleaves data, <a href="https://www.felixcloutier.com/x86/psllw:pslld:psllq"><code class="language-plaintext highlighter-rouge">psllq</code></a> performs parallel shifts, <a href="https://www.felixcloutier.com/x86/pand"><code class="language-plaintext highlighter-rouge">pand</code></a> performs bitwise AND with mask, and <a href="https://www.felixcloutier.com/x86/por"><code class="language-plaintext highlighter-rouge">por</code></a> combines results.</p> </li> </ol> <p>The compiler is working hard to vectorize a pattern that wasn’t originally written with SIMD in mind. While the memory accesses are actually sequential (reading consecutive elements 0, 1, 2, 3…), the compiler must use intricate shuffle operations (<code class="language-plaintext highlighter-rouge">punpcklqdq</code>) to reorganize this data into SIMD-friendly positions, which adds overhead.</p> <h2 id="the-vectorization-friendly-approach">The Vectorization-Friendly Approach</h2> <p>Now, let’s restructure the code slightly. Instead of reading consecutive elements and packing them together, we’ll change the access pattern together with manual loop unrolling:</p> <div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="nf">bitpack_vectorized</span><span class="p">(</span><span class="k">const</span> <span class="kt">uint64_t</span><span class="o">*</span> <span class="kr">__restrict</span> <span class="n">input</span><span class="p">,</span> <span class="kt">uint64_t</span><span class="o">*</span> <span class="kr">__restrict</span> <span class="n">output</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">128</span><span class="p">;</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">uint64_t</span> <span class="n">acc</span> <span class="o">=</span> <span class="n">input</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">;</span>
        <span class="n">acc</span> <span class="o">|=</span> <span class="p">(</span><span class="n">input</span><span class="p">[</span><span class="n">i</span> <span class="o">+</span> <span class="mi">128</span><span class="p">]</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">8</span><span class="p">;</span>
        <span class="n">acc</span> <span class="o">|=</span> <span class="p">(</span><span class="n">input</span><span class="p">[</span><span class="n">i</span> <span class="o">+</span> <span class="mi">256</span><span class="p">]</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">16</span><span class="p">;</span>
        <span class="n">acc</span> <span class="o">|=</span> <span class="p">(</span><span class="n">input</span><span class="p">[</span><span class="n">i</span> <span class="o">+</span> <span class="mi">384</span><span class="p">]</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">24</span><span class="p">;</span>
        <span class="n">acc</span> <span class="o">|=</span> <span class="p">(</span><span class="n">input</span><span class="p">[</span><span class="n">i</span> <span class="o">+</span> <span class="mi">512</span><span class="p">]</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">32</span><span class="p">;</span>
        <span class="n">acc</span> <span class="o">|=</span> <span class="p">(</span><span class="n">input</span><span class="p">[</span><span class="n">i</span> <span class="o">+</span> <span class="mi">640</span><span class="p">]</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">40</span><span class="p">;</span>
        <span class="n">acc</span> <span class="o">|=</span> <span class="p">(</span><span class="n">input</span><span class="p">[</span><span class="n">i</span> <span class="o">+</span> <span class="mi">768</span><span class="p">]</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">48</span><span class="p">;</span>
        <span class="n">acc</span> <span class="o">|=</span> <span class="p">(</span><span class="n">input</span><span class="p">[</span><span class="n">i</span> <span class="o">+</span> <span class="mi">896</span><span class="p">]</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">56</span><span class="p">;</span>
        <span class="n">output</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="n">acc</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>So what’s changed? Instead of reading 8 consecutive input values, we’re now reading values that are 128 elements apart: <code class="language-plaintext highlighter-rouge">i</code>, <code class="language-plaintext highlighter-rouge">i + 128</code>, <code class="language-plaintext highlighter-rouge">i + 256</code>, and so on.</p> <p>Why this particular pattern? We’re processing 1024 values, packing 8 bits from each value. This means we’ll produce 128 output values (1024 / 8 = 128). Each iteration handles one output value by reading from 8 locations spaced 128 elements apart. This stride length (128) isn’t arbitrary - it’s the number of output values we’re producing, which ensures each iteration processes one value from each “slice” of the input array.</p> <p>The generated assembly for this version looks like this:</p> <div class="language-nasm highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">; Same mask setup as before</span>
<span class="nf">movdqa</span>  <span class="nv">.LCPI8_0</span><span class="p">(</span><span class="o">%</span><span class="nv">rip</span><span class="p">),</span> <span class="o">%</span><span class="nv">xmm0</span>      <span class="c1">; Load masks into XMM registers</span>
<span class="c1">; ... (other masks)</span>

<span class="nl">.LBB8_1:</span>                           <span class="c1">; Main loop</span>
  <span class="c1">; Load 16 bytes (2 uint64_t values) with simple, contiguous access</span>
  <span class="nf">movdqu</span>  <span class="p">(</span><span class="o">%</span><span class="nb">rdi</span><span class="p">,</span><span class="o">%</span><span class="nb">rax</span><span class="p">,</span><span class="mi">8</span><span class="p">),</span> <span class="o">%</span><span class="nv">xmm7</span>     <span class="c1">; Load 2 contiguous values from indices 0 and 1 (data address 0 + base, base = %rdi + %rax*8) into xmm7</span>
  <span class="nf">pand</span>    <span class="o">%</span><span class="nv">xmm0</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm7</span>             <span class="c1">; Apply mask to extract relevant bytes, result in xmm7</span>

  <span class="c1">; Load from next stride - still contiguous relative to loop counter</span>
  <span class="nf">movdqu</span>  <span class="mi">1024</span><span class="p">(</span><span class="o">%</span><span class="nb">rdi</span><span class="p">,</span><span class="o">%</span><span class="nb">rax</span><span class="p">,</span><span class="mi">8</span><span class="p">),</span> <span class="o">%</span><span class="nv">xmm8</span> <span class="c1">; Load 2 contiguous values from indices 128 and 129 (1024 + base, 1024 = 128 values * 8 bytes each, base = %rdi + %rax*8) into xmm8</span>
  <span class="nf">psllq</span>   <span class="kc">$</span><span class="mi">8</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm8</span>                <span class="c1">; Shift left by 8 bits to position byte correctly, result in xmm8</span>
  <span class="nf">pand</span>    <span class="o">%</span><span class="nv">xmm1</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm8</span>             <span class="c1">; Apply mask to extract relevant bytes, result in xmm8</span>

  <span class="nf">por</span>     <span class="o">%</span><span class="nv">xmm7</span><span class="p">,</span> <span class="o">%</span><span class="nv">xmm8</span>             <span class="c1">; Combine with previous, result in xmm8</span>

  <span class="c1">; ... (similar operations for remaining bytes)</span>

  <span class="nf">movdqu</span>  <span class="o">%</span><span class="nv">xmm8</span><span class="p">,</span> <span class="p">(</span><span class="o">%</span><span class="nb">rsi</span><span class="p">,</span><span class="o">%</span><span class="nb">rax</span><span class="p">,</span><span class="mi">8</span><span class="p">)</span>     <span class="c1">; Store packed result into output</span>

  <span class="c1">; Loop counter increment and check</span>
  <span class="nf">addq</span>    <span class="kc">$</span><span class="mi">2</span><span class="p">,</span> <span class="o">%</span><span class="nb">rax</span>                 <span class="c1">; Increment index by 2</span>
  <span class="nf">cmpq</span>    <span class="kc">$</span><span class="mi">128</span><span class="p">,</span> <span class="o">%</span><span class="nb">rax</span>               <span class="c1">; Compare index with 128</span>
  <span class="nf">jne</span>     <span class="nv">.LBB8_1</span>                  <span class="c1">; Jump back if not done</span>
</code></pre></div></div> <h2 id="why-is-this-faster">Why Is This Faster?</h2> <p>To visualize the difference:</p> <ul> <li>Naive approach - reads consecutive elements, requires shuffling: <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Iteration i=0,1: read [0,8] [1,9] [2,10]... [7,15] → shuffle → pack → output[0,1]
Iteration i=2,3: read [16,24] [17,25] [18,26]... [23,31] → shuffle → pack → output[2,3]
...
Iteration i=126,127: read [1008,1016] [1009,1017]... [1015,1023] → shuffle → pack → output[126,127]
</code></pre></div> </div> <p>Problem: To vectorize, must load <code class="language-plaintext highlighter-rouge">[0]</code> and <code class="language-plaintext highlighter-rouge">[8]</code> together, <code class="language-plaintext highlighter-rouge">[1]</code> and <code class="language-plaintext highlighter-rouge">[9]</code> together, etc. Requires <code class="language-plaintext highlighter-rouge">punpcklqdq</code> to interleave non-adjacent values.</p> </li> <li>Vectorized approach - reads strided elements, naturally aligned. Input conceptually divided into 8 slices of 128 elements: <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Iteration i=0,1: read [0,1] [128,129] [256,257]... [896,897] → pack → output[0,1]
Iteration i=2,3: read [2,3] [130,131] [258,259]... [898,899] → pack → output[2,3]
...
Iteration i=126,127: read [126,127] [254,255]... [1022,1023] → pack → output[126,127]
</code></pre></div> </div> <p>Advantage: Loading <code class="language-plaintext highlighter-rouge">[0,1]</code> or <code class="language-plaintext highlighter-rouge">[128,129]</code> is a single <a href="https://www.felixcloutier.com/x86/movdqu:vmovdqu8:vmovdqu16:vmovdqu32:vmovdqu64"><code class="language-plaintext highlighter-rouge">movdqu</code></a> instruction. Values naturally align to SIMD lanes - no shuffling needed.</p> </li> </ul> <p>The key differences from the naive version:</p> <ol> <li> <p><strong>Simpler memory access pattern</strong>. While the accesses are strided rather than sequential, they map naturally to SIMD lanes without requiring shuffle operations. Instead of the complex <code class="language-plaintext highlighter-rouge">punpcklqdq</code> instructions, we now have straightforward <code class="language-plaintext highlighter-rouge">movdqu</code> (move unaligned double quadword) loads from predictable offsets: base, base+1024, base+2048, etc.</p> </li> <li> <p><strong>No data reorganization needed</strong>. The naive version had to use <code class="language-plaintext highlighter-rouge">punpcklqdq</code> to interleave data from different memory locations. The vectorized version eliminates this entirely - the data is already in the right layout for SIMD processing.</p> </li> <li> <p><strong>Better instruction-level parallelism</strong>. The simpler instruction sequence allows the CPU’s out-of-order execution to work more effectively. There are fewer dependencies between operations.</p> </li> <li> <p><strong>Cache-friendly for this workload</strong>. While strided access can sometimes have worse cache behavior than sequential access, our working set (1024 × 8 = 8KB) fits comfortably in L1 cache, so the strided pattern doesn’t introduce cache penalties. For much larger datasets, this trade-off would need careful consideration.</p> </li> </ol> <p>The benchmark results speak for themselves: <strong>the vectorized version is approximately 2x faster than the naive implementation</strong>.</p> <p>Benchmark Results produced with <a href="https://github.com/martinus/nanobench"><code class="language-plaintext highlighter-rouge">nanobench</code></a> on <code class="language-plaintext highlighter-rouge">Debian GNU/Linux 12 bookworm (x86-64)</code> with <code class="language-plaintext highlighter-rouge">Intel© Core™ i7-10510U CPU @ 1.80GHz × 4</code>:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>| relative |  ns/op |         op/s | err% | total | benchmark
|---------:|-------:|-------------:|-----:|------:|:------------
|   100.0% | 499.44 | 2,002,232.02 | 0.0% |  0.12 | `naive`
|   212.9% | 234.64 | 4,261,819.64 | 0.2% |  0.06 | `vectorized`
</code></pre></div></div> <p>This example demonstrates a counterintuitive principle in performance optimization: the way we access our data matters more than we might think. By restructuring array accesses to align with how SIMD instructions operate, we can help the compiler generate dramatically more efficient code - even when the change seems to make the algorithm “worse” from a traditional optimization perspective.</p> <h2 id="bonus-the-same-principle-in-c">Bonus: The Same Principle in C#</h2> <p>While we’ve been exploring C++ throughout this article, the principle of SIMD-friendly access patterns applies to other languages as well. C# provides explicit SIMD support through the <a href="https://learn.microsoft.com/en-us/dotnet/api/system.runtime.intrinsics"><code class="language-plaintext highlighter-rouge">System.Runtime.Intrinsics</code></a> namespace, allowing to write vectorized code directly.</p> <p>Unlike C++, where the compiler often auto-vectorizes loops, C# typically requires to write explicit SIMD code using intrinsics. However, the core insight remains the same: the strided access pattern we discovered translates directly to better SIMD performance in C# as well.</p> <p>Here’s how both approaches look in C#:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">static</span> <span class="k">class</span> <span class="nc">BitPacker</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="k">const</span> <span class="kt">int</span> <span class="n">ArraySize</span> <span class="p">=</span> <span class="m">1024</span><span class="p">;</span>
    
    <span class="k">public</span> <span class="k">static</span> <span class="k">void</span> <span class="nf">PackNaive</span><span class="p">(</span><span class="n">ReadOnlySpan</span><span class="p">&lt;</span><span class="kt">ulong</span><span class="p">&gt;</span> <span class="n">input</span><span class="p">,</span> <span class="n">Span</span><span class="p">&lt;</span><span class="kt">ulong</span><span class="p">&gt;</span> <span class="n">output</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="p">=</span> <span class="m">0</span><span class="p">,</span> <span class="n">j</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="p">&lt;</span> <span class="n">input</span><span class="p">.</span><span class="n">Length</span><span class="p">;</span> <span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">ulong</span> <span class="n">acc</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span>
            
            <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">k</span> <span class="p">=</span> <span class="m">0</span><span class="p">,</span> <span class="n">shift</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">k</span> <span class="p">&lt;</span> <span class="m">8</span><span class="p">;</span> <span class="p">++</span><span class="n">k</span><span class="p">,</span> <span class="p">++</span><span class="n">i</span><span class="p">,</span> <span class="n">shift</span> <span class="p">+=</span> <span class="m">8</span><span class="p">)</span>
                <span class="n">acc</span> <span class="p">|=</span> <span class="p">(</span><span class="n">input</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="p">&amp;</span> <span class="m">0xFF</span><span class="p">)</span> <span class="p">&lt;&lt;</span> <span class="n">shift</span><span class="p">;</span>
            
            <span class="n">output</span><span class="p">[</span><span class="n">j</span><span class="p">++]</span> <span class="p">=</span> <span class="n">acc</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
    
    <span class="k">public</span> <span class="k">static</span> <span class="k">unsafe</span> <span class="k">void</span> <span class="nf">PackSIMD</span><span class="p">(</span><span class="n">ReadOnlySpan</span><span class="p">&lt;</span><span class="kt">ulong</span><span class="p">&gt;</span> <span class="n">input</span><span class="p">,</span> <span class="n">Span</span><span class="p">&lt;</span><span class="kt">ulong</span><span class="p">&gt;</span> <span class="n">output</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="c1">// We need unsafe code to work with pointers for intrinsics</span>
        <span class="k">fixed</span> <span class="p">(</span><span class="kt">ulong</span><span class="p">*</span> <span class="n">inputPtr</span> <span class="p">=</span> <span class="n">input</span><span class="p">)</span> <span class="c1">// Pin input span to get a pointer</span>
        <span class="k">fixed</span> <span class="p">(</span><span class="kt">ulong</span><span class="p">*</span> <span class="n">outputPtr</span> <span class="p">=</span> <span class="n">output</span><span class="p">)</span> <span class="c1">// Pin output span to get a pointer</span>
        <span class="p">{</span>
            <span class="c1">// Pre-create mask vectors for each byte position</span>
            <span class="kt">var</span> <span class="n">mask0</span> <span class="p">=</span> <span class="n">Vector128</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">).</span><span class="nf">AsUInt64</span><span class="p">();</span>
            <span class="kt">var</span> <span class="n">mask1</span> <span class="p">=</span> <span class="n">Vector128</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">).</span><span class="nf">AsUInt64</span><span class="p">();</span>
            <span class="kt">var</span> <span class="n">mask2</span> <span class="p">=</span> <span class="n">Vector128</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">).</span><span class="nf">AsUInt64</span><span class="p">();</span>
            <span class="kt">var</span> <span class="n">mask3</span> <span class="p">=</span> <span class="n">Vector128</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">).</span><span class="nf">AsUInt64</span><span class="p">();</span>
            <span class="kt">var</span> <span class="n">mask4</span> <span class="p">=</span> <span class="n">Vector128</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">).</span><span class="nf">AsUInt64</span><span class="p">();</span>
            <span class="kt">var</span> <span class="n">mask5</span> <span class="p">=</span> <span class="n">Vector128</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">).</span><span class="nf">AsUInt64</span><span class="p">();</span>
            <span class="kt">var</span> <span class="n">mask6</span> <span class="p">=</span> <span class="n">Vector128</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0xFF</span><span class="p">,</span> <span class="m">0</span><span class="p">).</span><span class="nf">AsUInt64</span><span class="p">();</span>

            <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="p">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="p">&lt;</span> <span class="m">128</span><span class="p">;</span> <span class="n">i</span> <span class="p">+=</span> <span class="m">2</span><span class="p">)</span>
            <span class="p">{</span>
                <span class="n">Sse2</span><span class="p">.</span><span class="nf">Or</span><span class="p">(</span> <span class="c1">// Combine two output values</span>
                    <span class="n">Sse2</span><span class="p">.</span><span class="nf">Or</span><span class="p">(</span> <span class="c1">// Combine first four loads</span>
                        <span class="n">Sse2</span><span class="p">.</span><span class="nf">Or</span><span class="p">(</span> <span class="c1">// Combine first four loads</span>
                            <span class="n">Sse2</span><span class="p">.</span><span class="nf">And</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">LoadVector128</span><span class="p">(</span><span class="n">inputPtr</span> <span class="p">+</span> <span class="n">i</span><span class="p">),</span> <span class="n">mask0</span><span class="p">),</span> <span class="c1">// First 128-bit load</span>
                            <span class="n">Sse2</span><span class="p">.</span><span class="nf">And</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">ShiftLeftLogical</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">LoadVector128</span><span class="p">(</span><span class="n">inputPtr</span> <span class="p">+</span> <span class="n">i</span> <span class="p">+</span> <span class="m">128</span><span class="p">),</span> <span class="m">8</span><span class="p">),</span> <span class="n">mask1</span><span class="p">)),</span> <span class="c1">// Second 128-bit load</span>
                        <span class="n">Sse2</span><span class="p">.</span><span class="nf">Or</span><span class="p">(</span> <span class="c1">// Combine third and fourth loads</span>
                            <span class="n">Sse2</span><span class="p">.</span><span class="nf">And</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">ShiftLeftLogical</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">LoadVector128</span><span class="p">(</span><span class="n">inputPtr</span> <span class="p">+</span> <span class="n">i</span> <span class="p">+</span> <span class="m">256</span><span class="p">),</span> <span class="m">16</span><span class="p">),</span> <span class="n">mask2</span><span class="p">),</span> <span class="c1">// Third 128-bit load</span>
                            <span class="n">Sse2</span><span class="p">.</span><span class="nf">And</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">ShiftLeftLogical</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">LoadVector128</span><span class="p">(</span><span class="n">inputPtr</span> <span class="p">+</span> <span class="n">i</span> <span class="p">+</span> <span class="m">384</span><span class="p">),</span> <span class="m">24</span><span class="p">),</span> <span class="n">mask3</span><span class="p">))),</span> <span class="c1">// Fourth 128-bit load</span>
                    <span class="n">Sse2</span><span class="p">.</span><span class="nf">Or</span><span class="p">(</span> <span class="c1">// Combine last four loads</span>
                        <span class="n">Sse2</span><span class="p">.</span><span class="nf">Or</span><span class="p">(</span> <span class="c1">// Combine fifth and sixth loads</span>
                            <span class="n">Sse2</span><span class="p">.</span><span class="nf">And</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">ShiftLeftLogical</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">LoadVector128</span><span class="p">(</span><span class="n">inputPtr</span> <span class="p">+</span> <span class="n">i</span> <span class="p">+</span> <span class="m">512</span><span class="p">),</span> <span class="m">32</span><span class="p">),</span> <span class="n">mask4</span><span class="p">),</span> <span class="c1">// Fifth 128-bit load</span>
                            <span class="n">Sse2</span><span class="p">.</span><span class="nf">And</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">ShiftLeftLogical</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">LoadVector128</span><span class="p">(</span><span class="n">inputPtr</span> <span class="p">+</span> <span class="n">i</span> <span class="p">+</span> <span class="m">640</span><span class="p">),</span> <span class="m">40</span><span class="p">),</span> <span class="n">mask5</span><span class="p">)),</span> <span class="c1">// Sixth 128-bit load</span>
                        <span class="n">Sse2</span><span class="p">.</span><span class="nf">Or</span><span class="p">(</span> <span class="c1">// Combine seventh and eighth loads</span>
                            <span class="n">Sse2</span><span class="p">.</span><span class="nf">And</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">ShiftLeftLogical</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">LoadVector128</span><span class="p">(</span><span class="n">inputPtr</span> <span class="p">+</span> <span class="n">i</span> <span class="p">+</span> <span class="m">768</span><span class="p">),</span> <span class="m">48</span><span class="p">),</span> <span class="n">mask6</span><span class="p">),</span> <span class="c1">// Seventh 128-bit load</span>
                            <span class="n">Sse2</span><span class="p">.</span><span class="nf">ShiftLeftLogical</span><span class="p">(</span><span class="n">Sse2</span><span class="p">.</span><span class="nf">LoadVector128</span><span class="p">(</span><span class="n">inputPtr</span> <span class="p">+</span> <span class="n">i</span> <span class="p">+</span> <span class="m">896</span><span class="p">),</span> <span class="m">56</span><span class="p">))))</span> <span class="c1">// Eighth 128-bit load</span>
                    <span class="p">.</span><span class="nf">Store</span><span class="p">(</span><span class="n">outputPtr</span> <span class="p">+</span> <span class="n">i</span><span class="p">);</span> <span class="c1">// Store the packed result</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>This is the straightforward translation of original assembly code - reading consecutive elements and packing them together.</p> <p>The SIMD version follows the same pattern we discovered:</p> <ul> <li>We access the input array with strides of 128 elements</li> <li>Load pairs of values into <code class="language-plaintext highlighter-rouge">Vector128&lt;ulong&gt;</code> registers</li> <li>Apply shifts and masks using SSE2 intrinsics</li> <li>Combine the results with bitwise <code class="language-plaintext highlighter-rouge">OR</code> operations</li> </ul> <p>The notable differences from C++ is explicit intrinsics. While the C++ compiler is able to auto-vectorize code, in C# we’re directly calling methods like <code class="language-plaintext highlighter-rouge">Sse2.ShiftLeftLogical</code> and <code class="language-plaintext highlighter-rouge">Sse2.And</code>. This gives us fine-grained control but requires more verbose code. Of course, it is possible to be that explicit in C++ as well using intrinsics, but auto-vectorization is often more convenient, nevertheless understanding how to write SIMD-friendly code is crucial.</p> <p>Benchmarking with <a href="https://benchmarkdotnet.org/"><code class="language-plaintext highlighter-rouge">BenchmarkDotNet</code></a> on <code class="language-plaintext highlighter-rouge">Debian GNU/Linux 12 bookworm (x86-64)</code> with <code class="language-plaintext highlighter-rouge">Intel© Core™ i7-10510U CPU @ 1.80GHz × 4</code>:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>| Method | Mean       | Error   | StdDev  | Ratio |
|------- |-----------:|--------:|--------:|------:|
| Naive  | 1,358.0 ns | 3.00 ns | 2.50 ns |  1.00 |
| SIMD   |   311.5 ns | 3.47 ns | 3.07 ns |  0.23 |
</code></pre></div></div> <p>SIMD implementation significantly (about 4.3x) outperforms the naive scalar approach, demonstrating that the principle of SIMD-friendly access patterns is language-agnostic. The lesson carries across: whether you’re writing C++, C#, or any other language with SIMD support, thinking about how your data access patterns align with SIMD operations is crucial for achieving optimal performance.</p>]]></content><author><name>Nazarii Piontko</name></author><category term="optimizations"/><category term="c++"/><category term="asm"/><category term="simd"/><category term="sse"/><summary type="html"><![CDATA[A practical exploration of how restructuring array accesses in C++ can help compilers generate vectorized code that's 2x faster, with detailed assembly analysis of bitpacking implementations.]]></summary></entry><entry><title type="html">Deploying .NET Aspire to AWS</title><link href="https://www.npiontko.pro/2025/11/14/aspire-aws-deployment" rel="alternate" type="text/html" title="Deploying .NET Aspire to AWS"/><published>2025-11-14T00:00:00+00:00</published><updated>2025-11-14T00:00:00+00:00</updated><id>https://www.npiontko.pro/2025/11/14/aspire-aws-deployment</id><content type="html" xml:base="https://www.npiontko.pro/2025/11/14/aspire-aws-deployment"><![CDATA[<p>In the <a href="/2025/11/03/aspire-localstack">previous article</a> I set up a local development loop using Aspire + LocalStack. No AWS costs, fast iterations, AWS services emulation. The natural next question: how do we deploy this same application to AWS environments (testing, staging, production) without maintaining separate infrastructure code? And can we even do this while keeping everything in C#? Long story short: yes, but with some trade-offs.</p> <p>This article shows the pattern: one Aspire Host project that switches between local emulation and real AWS deployment based on execution context. When running locally, Aspire wires up LocalStack and containers. When publishing, the same file hands off to an AWS CDK stack that provisions VPC, Aurora Serverless, DynamoDB, Lambda, and API Gateway. Same logical service architecture (API → Database, API → DynamoDB), slightly different infrastructure implementations.</p> <p>Before diving into code, the key constraint: Aspire today (late 2025) doesn’t have a native publisher for AWS. It can’t package a .NET project into a Lambda-compatible zip, manage versions, or model AWS-specific resources like RDS Proxy or VPC endpoints. Luckily, AWS CDK already solves all of this. So the pattern is: <strong>Aspire orchestrates (decides local vs publish, wires references), CDK provisions (synthesizes CloudFormation, bundles Lambda assets, deploys to AWS)</strong>. This keeps everything in one place without waiting for Aspire to grow full AWS deployment primitives. Think of it as Aspire being the high-level orchestrator that knows about services and their relationships, while CDK is the specialized tool that knows exactly how to package .NET code for Lambda, create VPC configurations, and wire up security groups. Each tool does what it does best.</p> <h2 id="what-well-build">What We’ll Build</h2> <p>We’ll deploy a sample application based on a serverless services: a Lambda function running our API, Aurora Serverless v2 for PostgreSQL, DynamoDB for auxiliary storage, and API Gateway routing HTTP traffic. Everything runs in a private VPC with no direct internet access except the API Gateway endpoint.</p> <p>During local development, Aspire spins up LocalStack to emulate DynamoDB and other AWS services, plus a Postgres container for the database. When publishing, we’ll provision real AWS resources.</p> <p><strong>Local mode</strong>:</p> <ul> <li>LocalStack emulates DynamoDB</li> <li>Local Postgres container for the database</li> <li>AWS Lambda emulator runs the API locally</li> <li>API Gateway emulator routes HTTP requests</li> </ul> <p><strong>AWS mode</strong>:</p> <ul> <li>CDK stack provisions real AWS resources</li> <li>VPC</li> <li>Aurora Serverless v2 + RDS Proxy</li> <li>DynamoDB table</li> <li>Lambda function</li> <li>HTTP API Gateway</li> </ul> <h2 id="the-aspire-host">The Aspire Host</h2> <p>Now that we’ve outlined the architectural differences, let’s see how Aspire orchestrates this dual-mode behavior. The key insight is that Aspire can detect whether it’s running in local development mode or publish mode (when deploying to AWS), and conditionally wire up different infrastructure providers based on that context.</p> <p>The entire orchestration logic lives in a single host file. There’s no separate deployment configuration, no CI/CD YAML with infrastructure definitions scattered across multiple files. Instead, we use Aspire’s <code class="language-plaintext highlighter-rouge">ExecutionContext.IsPublishMode</code> to branch between local emulation and AWS deployment:</p> <p>Here’s the complete branching logic:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Select between local emulation and AWS deployment</span>
<span class="c1">// IsPublishMode is true when running 'dotnet run --project Host -- --publisher ...'</span>
<span class="k">if</span> <span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">ExecutionContext</span><span class="p">.</span><span class="n">IsPublishMode</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">// AWS SDK configuration</span>
    <span class="kt">var</span> <span class="n">awsConfig</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSSDKConfig</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">WithProfile</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetValue</span><span class="p">(</span><span class="s">"AWS:Profile"</span><span class="p">))</span>
        <span class="p">.</span><span class="nf">WithRegion</span><span class="p">(</span><span class="n">RegionEndpoint</span><span class="p">.</span><span class="nf">GetBySystemName</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetValue</span><span class="p">(</span><span class="s">"AWS:Region"</span><span class="p">)));</span>

    <span class="c1">// CDK stack provisioning full production infrastructure</span>
    <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSCDKStack</span><span class="p">(</span><span class="s">"AwsSampleStack"</span><span class="p">,</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">SampleStack</span><span class="p">(</span><span class="n">s</span><span class="p">))</span>
        <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">awsConfig</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">else</span>
<span class="p">{</span>
    <span class="c1">// AWS SDK configuration for LocalStack</span>
    <span class="kt">var</span> <span class="n">awsConfig</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSSDKConfig</span><span class="p">()</span>
        <span class="p">.</span><span class="nf">WithProfile</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetValue</span><span class="p">(</span><span class="s">"AWS:Profile"</span><span class="p">))</span>
        <span class="p">.</span><span class="nf">WithRegion</span><span class="p">(</span><span class="n">RegionEndpoint</span><span class="p">.</span><span class="nf">GetBySystemName</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetValue</span><span class="p">(</span><span class="s">"AWS:Region"</span><span class="p">)));</span>
    
    <span class="c1">// LocalStack setup</span>
    <span class="kt">var</span> <span class="n">awsLocal</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddLocalStack</span><span class="p">(</span><span class="s">"AwsLocal"</span><span class="p">,</span> <span class="n">awsConfig</span><span class="p">:</span> <span class="n">awsConfig</span><span class="p">);</span>
    
    <span class="c1">// CDK stack provisioning subset of resources for LocalStack</span>
    <span class="kt">var</span> <span class="n">awsStack</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSCDKStack</span><span class="p">(</span><span class="s">"AwsSampleBaseStack"</span><span class="p">,</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">SampleBaseStack</span><span class="p">(</span><span class="n">s</span><span class="p">))</span>
        <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">awsConfig</span><span class="p">);</span>

    <span class="c1">// Local Postgres container</span>
    <span class="kt">var</span> <span class="n">postgres</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddPostgres</span><span class="p">(</span><span class="s">"Postgres"</span><span class="p">);</span>

    <span class="c1">// Lambda function running the API locally</span>
    <span class="kt">var</span> <span class="n">api</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">AddAWSLambdaFunction</span><span class="p">&lt;</span><span class="n">Projects</span><span class="p">.</span><span class="n">Api</span><span class="p">&gt;(</span><span class="s">"Api"</span><span class="p">,</span> <span class="s">"Api"</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">awsStack</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">postgres</span><span class="p">);</span>

    <span class="c1">// API Gateway emulator routing all requests to the Lambda</span>
    <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSAPIGatewayEmulator</span><span class="p">(</span><span class="s">"ApiGatewayEmulator"</span><span class="p">,</span> <span class="n">APIGatewayType</span><span class="p">.</span><span class="n">HttpV2</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">api</span><span class="p">,</span> <span class="n">Method</span><span class="p">.</span><span class="n">Any</span><span class="p">,</span> <span class="s">"/{proxy+}"</span><span class="p">);</span>
    
    <span class="c1">// Wire up LocalStack</span>
    <span class="n">builder</span><span class="p">.</span><span class="nf">UseLocalStack</span><span class="p">(</span><span class="n">awsLocal</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div> <p><strong>Key differences:</strong></p> <ul> <li><strong>CDK Stack</strong>: Publish provisions <code class="language-plaintext highlighter-rouge">SampleStack</code> (full production infrastructure with VPC, Aurora, Lambda, API Gateway); local provisions <code class="language-plaintext highlighter-rouge">SampleBaseStack</code> (just a subset of AWS services for LocalStack to emulate, DynamoDB table)</li> <li><strong>Database</strong>: Local adds an explicit Postgres container resource; publish mode relies on the RDS Aurora cluster defined in the CDK stack</li> <li><strong>Lambda Reference</strong>: Local mode uses <code class="language-plaintext highlighter-rouge">.AddAWSLambdaFunction&lt;Projects.Api&gt;</code> to run the API as a Lambda emulator locally</li> </ul> <p>That single <code class="language-plaintext highlighter-rouge">IsPublishMode</code> check is the seam between emulation and deployment. When we run <code class="language-plaintext highlighter-rouge">dotnet run --project Host</code>, we get local mode. When we run <code class="language-plaintext highlighter-rouge">dotnet run --project Host -- --publisher ...</code>, we get publish mode and CDK takes over.</p> <h2 id="the-environment-parity-trade-off">The Environment Parity Trade-Off</h2> <p>Here’s the elephant in the room: this approach technically violates the principle of environment parity. Local mode uses Postgres in a container; production uses Aurora Serverless v2 with RDS Proxy. Local uses LocalStack’s DynamoDB emulation; production uses real DynamoDB. Local runs the API in a Lambda emulator; production runs it in actual Lambda with VPC networking, security groups, and IAM roles.</p> <p><strong>So why accept this split?</strong></p> <p>Perfect parity between local and cloud is rarely worth the cost. Emulating every managed service nuance (full VPC routing, Aurora serverless scaling behavior, RDS Proxy, IAM evaluation, realistic latency) on a laptop adds friction and slows the inner loop. The alternative - developing directly against live AWS - slows feedback (deploy per change), consumes budget, requires constant connectivity, and blocks offline work.</p> <p>So we aim for <strong>logical parity over physical parity</strong>: same code paths, dependency graph, configuration keys, contracts (HTTP/data/events), and instrumentation; different implementations tuned for their environment. Local maximizes iteration speed; production maximizes reliability, scalability, and security.</p> <p>What makes this work:</p> <ul> <li><strong>Same application code</strong>: The services code runs identically in both environments. We use a small provider pattern: a startup flag selects which dependencies to register (LocalStack endpoints + static Postgres password OR real AWS SDK clients + IAM auth token generator). Endpoints only depend on abstractions like <code class="language-plaintext highlighter-rouge">IDynamoDBContext</code> and <code class="language-plaintext highlighter-rouge">NpgsqlDataSource</code>, so no code changes are needed when swapping providers (we’ll see the API configuration details later).</li> <li><strong>Single repository ownership</strong>: Infrastructure and application code live together. No separate IaC repo, no coordination between teams, no deployment scripts scattered across CI/CD pipelines.</li> <li><strong>Reduced maintenance burden</strong>: When we add a new service (e.g., SQS queue), we add it once in the CDK stack. LocalStack automatically emulates it locally; CloudFormation provisions it in production. No duplicate YAML files, no drift.</li> <li><strong>Developer autonomy</strong>: Developers can iterate locally without AWS credentials, network access, or cloud costs. When ready, they deploy the same codebase with one command, either manually, either via CI/CD.</li> </ul> <p>The cost is awareness: developers need to know that Postgres and Aurora aren’t byte-for-byte identical (e.g., Aurora-specific features won’t work locally), and that LocalStack’s emulation has limitations. But this is a manageable trade-off compared to maintaining separate infrastructure repositories or forcing developers to develop against live AWS.</p> <h2 id="the-cdk-stack-aws-infrastructure">The CDK Stack: AWS Infrastructure</h2> <p>Now that we understand the orchestration strategy and trade-offs, let’s examine how CDK provisions the actual AWS infrastructure. Remember, in publish mode, the Aspire Host hands off to <code class="language-plaintext highlighter-rouge">SampleStack</code>, which defines all the production resources. Here’s what gets created:</p> <ol> <li><strong>VPC with isolated subnets</strong> → no internet gateway, no public IPs</li> <li><strong>Aurora Serverless v2 cluster</strong> → auto-scaling Postgres (0.5-1 ACU)</li> <li><strong>RDS Proxy</strong> → connection pooling + IAM auth for Lambda</li> <li><strong>DynamoDB table</strong> → table with partition key</li> <li><strong>Lambda function</strong> → .NET 8 runtime, bundled via Docker (AWS currently supports .NET 8 as bundled runtime)</li> <li><strong>HTTP API Gateway</strong> → single catch-all route <code class="language-plaintext highlighter-rouge">/{proxy+}</code> to Lambda</li> </ol> <p><strong>The Full CDK Code</strong></p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Base stack: Shared resources used by both LocalStack (local dev) and AWS (production).</span>
<span class="c1">// This stack only creates resources that LocalStack can emulate (e.g., DynamoDB).</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">SampleBaseStack</span> <span class="p">:</span> <span class="n">Stack</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="n">Table</span> <span class="n">DynamoDbTable</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>
    
    <span class="k">public</span> <span class="nf">SampleBaseStack</span><span class="p">(</span><span class="n">Construct</span> <span class="n">scope</span><span class="p">)</span>
        <span class="p">:</span> <span class="k">this</span><span class="p">(</span><span class="n">scope</span><span class="p">,</span> <span class="s">"SampleBaseStack"</span><span class="p">)</span>
    <span class="p">{</span>
    <span class="p">}</span>

    <span class="k">protected</span> <span class="nf">SampleBaseStack</span><span class="p">(</span><span class="n">Construct</span> <span class="n">scope</span><span class="p">,</span> <span class="kt">string</span> <span class="n">id</span><span class="p">)</span>
        <span class="p">:</span> <span class="k">base</span><span class="p">(</span><span class="n">scope</span><span class="p">,</span> <span class="n">id</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">DynamoDbTable</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Table</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"Table"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">TableProps</span>
            <span class="p">{</span>
                <span class="n">TableName</span> <span class="p">=</span> <span class="s">"sample-records"</span><span class="p">,</span>
                <span class="n">PartitionKey</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Attribute</span>
                <span class="p">{</span>
                    <span class="n">Name</span> <span class="p">=</span> <span class="s">"id"</span><span class="p">,</span>
                    <span class="n">Type</span> <span class="p">=</span> <span class="n">AttributeType</span><span class="p">.</span><span class="n">STRING</span>
                <span class="p">},</span>
                <span class="n">RemovalPolicy</span> <span class="p">=</span> <span class="n">RemovalPolicy</span><span class="p">.</span><span class="n">DESTROY</span> <span class="c1">// Demo only - use RETAIN in production</span>
            <span class="p">});</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// Production stack: Inherits shared resources, adds AWS-specific infrastructure.</span>
<span class="c1">// VPC, Aurora, RDS Proxy, Lambda, and API Gateway only exist in production.</span>
<span class="k">public</span> <span class="k">class</span> <span class="nc">SampleStack</span> <span class="p">:</span> <span class="n">SampleBaseStack</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="n">CfnOutput</span> <span class="n">PgConnectionString</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">public</span> <span class="n">CfnOutput</span> <span class="n">ApiUrl</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>
    
    <span class="k">public</span> <span class="nf">SampleStack</span><span class="p">(</span><span class="n">Construct</span> <span class="n">scope</span><span class="p">)</span>
        <span class="p">:</span> <span class="k">base</span><span class="p">(</span><span class="n">scope</span><span class="p">,</span> <span class="s">"SampleStack"</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="c1">// --- VPC: Isolated Network ---</span>
        <span class="c1">// PRIVATE_ISOLATED = no internet gateway, no NAT gateway, no public IPs.</span>
        <span class="c1">// Lambda and Aurora can only communicate within VPC or through VPC endpoints.</span>
        <span class="c1">// DynamoDB access via VPC endpoint (no internet traversal).</span>
        <span class="kt">var</span> <span class="n">privateSubnets</span> <span class="p">=</span> <span class="k">new</span> <span class="n">SubnetSelection</span> <span class="p">{</span> <span class="n">SubnetType</span> <span class="p">=</span> <span class="n">SubnetType</span><span class="p">.</span><span class="n">PRIVATE_ISOLATED</span> <span class="p">};</span>

        <span class="kt">var</span> <span class="n">vpc</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Vpc</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"ClusterVPC"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">VpcProps</span>
            <span class="p">{</span>
                <span class="n">MaxAzs</span> <span class="p">=</span> <span class="m">2</span><span class="p">,</span> <span class="c1">// High availability across 2 availability zones</span>
                <span class="n">VpcName</span> <span class="p">=</span> <span class="s">"sample-cluster-vpc"</span><span class="p">,</span>
                <span class="n">SubnetConfiguration</span> <span class="p">=</span>
                <span class="p">[</span>
                    <span class="k">new</span> <span class="n">SubnetConfiguration</span>
                    <span class="p">{</span>
                        <span class="n">Name</span> <span class="p">=</span> <span class="s">"private"</span><span class="p">,</span>
                        <span class="n">SubnetType</span> <span class="p">=</span> <span class="n">SubnetType</span><span class="p">.</span><span class="n">PRIVATE_ISOLATED</span><span class="p">,</span>
                        <span class="n">CidrMask</span> <span class="p">=</span> <span class="m">24</span>
                    <span class="p">}</span>
                <span class="p">],</span>
                <span class="c1">// VPC Gateway Endpoint for DynamoDB: Lambda can reach DynamoDB without internet access.</span>
                <span class="c1">// Traffic stays within AWS network, improves security and reduces latency.</span>
                <span class="n">GatewayEndpoints</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</span> <span class="n">IGatewayVpcEndpointOptions</span><span class="p">&gt;</span>
                <span class="p">{</span>
                    <span class="p">{</span>
                        <span class="s">"DynamoDbEndpoint"</span><span class="p">,</span>
                        <span class="k">new</span> <span class="n">GatewayVpcEndpointOptions</span>
                        <span class="p">{</span>
                            <span class="n">Service</span> <span class="p">=</span> <span class="n">GatewayVpcEndpointAwsService</span><span class="p">.</span><span class="n">DYNAMODB</span><span class="p">,</span>
                            <span class="n">Subnets</span> <span class="p">=</span> <span class="p">[</span><span class="n">privateSubnets</span><span class="p">]</span>
                        <span class="p">}</span>
                    <span class="p">}</span>
                <span class="p">}</span>
            <span class="p">});</span>
        
        <span class="c1">// --- Security Groups: Firewall Rules ---</span>
        <span class="c1">// Separate security groups follow least-privilege principle.</span>
        <span class="c1">// We'll configure rules later to allow Lambda → RDS Proxy communication.</span>
        <span class="kt">var</span> <span class="n">dbSg</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">SecurityGroup</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"DatabaseSecurityGroup"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">SecurityGroupProps</span>
            <span class="p">{</span>
                <span class="n">SecurityGroupName</span> <span class="p">=</span> <span class="s">"db-sg"</span><span class="p">,</span>
                <span class="n">Vpc</span> <span class="p">=</span> <span class="n">vpc</span>
            <span class="p">});</span>
        
        <span class="kt">var</span> <span class="n">lambdaSg</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">SecurityGroup</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"LambdaSecurityGroup"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">SecurityGroupProps</span>
            <span class="p">{</span>
                <span class="n">SecurityGroupName</span> <span class="p">=</span> <span class="s">"lambda-sg"</span><span class="p">,</span>
                <span class="n">Vpc</span> <span class="p">=</span> <span class="n">vpc</span>
            <span class="p">});</span>

        <span class="c1">// --- RDS Aurora PostgreSQL Cluster ---</span>
        <span class="c1">// Aurora Serverless v2: Scales capacity automatically based on load (0.5-1 ACU here).</span>
        <span class="c1">// Pay only for what you use, ideal for variable workloads.</span>
        <span class="k">const</span> <span class="kt">string</span> <span class="n">pgUser</span> <span class="p">=</span> <span class="s">"lambda"</span><span class="p">;</span>
        <span class="k">const</span> <span class="kt">string</span><span class="p">?</span> <span class="n">pgDatabaseName</span> <span class="p">=</span> <span class="s">"sample"</span><span class="p">;</span>

        <span class="kt">var</span> <span class="n">pg</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DatabaseCluster</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"DatabaseCluster"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">DatabaseClusterProps</span>
            <span class="p">{</span>
                <span class="n">Engine</span> <span class="p">=</span> <span class="n">DatabaseClusterEngine</span><span class="p">.</span><span class="nf">AuroraPostgres</span><span class="p">(</span><span class="k">new</span> <span class="n">AuroraPostgresClusterEngineProps</span>
                <span class="p">{</span>
                    <span class="n">Version</span> <span class="p">=</span> <span class="n">AuroraPostgresEngineVersion</span><span class="p">.</span><span class="n">VER_17_5</span>
                <span class="p">}),</span>
                <span class="n">Writer</span> <span class="p">=</span> <span class="n">ClusterInstance</span><span class="p">.</span><span class="nf">ServerlessV2</span><span class="p">(</span><span class="s">"SampleDatabaseClusterWriter"</span><span class="p">,</span>
                    <span class="k">new</span> <span class="n">ServerlessV2ClusterInstanceProps</span>
                    <span class="p">{</span>
                        <span class="n">PubliclyAccessible</span> <span class="p">=</span> <span class="k">false</span><span class="p">,</span> <span class="c1">// Must be false for PRIVATE_ISOLATED subnets</span>
                        <span class="n">EnablePerformanceInsights</span> <span class="p">=</span> <span class="k">false</span>
                    <span class="p">}),</span>
                <span class="n">ServerlessV2MinCapacity</span> <span class="p">=</span> <span class="m">0.5</span><span class="p">,</span>
                <span class="n">ServerlessV2MaxCapacity</span> <span class="p">=</span> <span class="m">1</span><span class="p">,</span>
                <span class="n">Vpc</span> <span class="p">=</span> <span class="n">vpc</span><span class="p">,</span>
                <span class="n">VpcSubnets</span> <span class="p">=</span> <span class="n">privateSubnets</span><span class="p">,</span>
                <span class="n">SecurityGroups</span> <span class="p">=</span> <span class="p">[</span><span class="n">dbSg</span><span class="p">],</span>
                <span class="n">Credentials</span> <span class="p">=</span> <span class="n">Credentials</span><span class="p">.</span><span class="nf">FromGeneratedSecret</span><span class="p">(</span><span class="n">pgUser</span><span class="p">),</span> <span class="c1">// Password stored in Secrets Manager</span>
                <span class="n">DefaultDatabaseName</span> <span class="p">=</span> <span class="n">pgDatabaseName</span><span class="p">,</span>
                <span class="n">EnableDataApi</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>
                <span class="n">RemovalPolicy</span> <span class="p">=</span> <span class="n">RemovalPolicy</span><span class="p">.</span><span class="n">DESTROY</span><span class="p">,</span> <span class="c1">// Demo only - use RETAIN in production</span>
                <span class="n">DeletionProtection</span> <span class="p">=</span> <span class="k">false</span>
            <span class="p">});</span>

        <span class="c1">// --- RDS Proxy: Connection Pooling + IAM Auth ---</span>
        <span class="c1">// Why RDS Proxy? Lambda might create many concurrent connections. Without pooling,</span>
        <span class="c1">// Aurora will quickly exhaust max_connections. Proxy multiplexes Lambda connections</span>
        <span class="c1">// into a smaller pool, prevents "too many connections" errors.</span>
        <span class="c1">// IAM auth eliminates password management - Lambda uses its role to authenticate.</span>
        <span class="kt">var</span> <span class="n">pgProxy</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">DatabaseProxy</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"DatabaseClusterProxy"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">DatabaseProxyProps</span>
            <span class="p">{</span>
                <span class="n">DbProxyName</span> <span class="p">=</span> <span class="s">"sample-db-proxy"</span><span class="p">,</span>
                <span class="n">ProxyTarget</span> <span class="p">=</span> <span class="n">ProxyTarget</span><span class="p">.</span><span class="nf">FromCluster</span><span class="p">(</span><span class="n">pg</span><span class="p">),</span>
                <span class="n">Vpc</span> <span class="p">=</span> <span class="n">vpc</span><span class="p">,</span>
                <span class="n">VpcSubnets</span> <span class="p">=</span> <span class="n">privateSubnets</span><span class="p">,</span>
                <span class="n">SecurityGroups</span> <span class="p">=</span> <span class="p">[</span><span class="n">dbSg</span><span class="p">],</span>
                <span class="n">RequireTLS</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>    <span class="c1">// Enforce encrypted connections</span>
                <span class="n">IamAuth</span> <span class="p">=</span> <span class="k">true</span><span class="p">,</span>       <span class="c1">// Enable IAM database authentication (no passwords)</span>
                <span class="n">Secrets</span> <span class="p">=</span> <span class="p">[</span><span class="n">pg</span><span class="p">.</span><span class="n">Secret</span><span class="p">!]</span> <span class="c1">// Proxy uses this secret to connect to Aurora</span>
            <span class="p">});</span>
        
        <span class="c1">// Connection string points to RDS Proxy endpoint, not Aurora directly.</span>
        <span class="c1">// Lambda will generate IAM auth tokens at runtime (see Api/Program.cs).</span>
        <span class="n">PgConnectionString</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">CfnOutput</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="s">"DatabaseConnectionString"</span><span class="p">,</span> <span class="k">new</span> <span class="n">CfnOutputProps</span>
        <span class="p">{</span>
            <span class="n">Value</span> <span class="p">=</span> <span class="s">$"Host=</span><span class="p">{</span><span class="n">pgProxy</span><span class="p">.</span><span class="n">Endpoint</span><span class="p">}</span><span class="s">;Port=5432;Username=</span><span class="p">{</span><span class="n">pgUser</span><span class="p">}</span><span class="s">;Database=</span><span class="p">{</span><span class="n">pgDatabaseName</span><span class="p">}</span><span class="s">;Ssl Mode=Require;Trust Server Certificate=true;"</span>
        <span class="p">});</span>
        
        <span class="c1">// Security group rule: Lambda can connect to RDS Proxy on port 5432</span>
        <span class="n">pgProxy</span><span class="p">.</span><span class="n">Connections</span><span class="p">.</span><span class="nf">AllowFrom</span><span class="p">(</span><span class="n">lambdaSg</span><span class="p">,</span> <span class="n">Port</span><span class="p">.</span><span class="n">POSTGRES</span><span class="p">,</span> <span class="s">"Lambda to Proxy"</span><span class="p">);</span>

        <span class="c1">// --- IAM Role for Lambda ---</span>
        <span class="c1">// Least-privilege principle: Lambda needs CloudWatch Logs, VPC networking, </span>
        <span class="c1">// RDS IAM auth, and DynamoDB access. Nothing more.</span>
        <span class="kt">var</span> <span class="n">lambdaRole</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Role</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"LambdaRole"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">RoleProps</span>
            <span class="p">{</span>
                <span class="n">RoleName</span> <span class="p">=</span> <span class="s">"sample-lambda-execution-role"</span><span class="p">,</span>
                <span class="n">AssumedBy</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">ServicePrincipal</span><span class="p">(</span><span class="s">"lambda.amazonaws.com"</span><span class="p">),</span>
                <span class="n">ManagedPolicies</span> <span class="p">=</span>
                <span class="p">[</span>
                    <span class="n">ManagedPolicy</span><span class="p">.</span><span class="nf">FromAwsManagedPolicyName</span><span class="p">(</span><span class="s">"service-role/AWSLambdaBasicExecutionRole"</span><span class="p">),</span>
                    <span class="n">ManagedPolicy</span><span class="p">.</span><span class="nf">FromAwsManagedPolicyName</span><span class="p">(</span><span class="s">"service-role/AWSLambdaVPCAccessExecutionRole"</span><span class="p">)</span>
                <span class="p">]</span>
            <span class="p">});</span>
        
        <span class="c1">// Grant fine-grained permissions: Lambda can generate RDS auth tokens and access DynamoDB table.</span>
        <span class="c1">// No wildcards, no overly broad policies.</span>
        <span class="n">pgProxy</span><span class="p">.</span><span class="nf">GrantConnect</span><span class="p">(</span><span class="n">lambdaRole</span><span class="p">,</span> <span class="n">pgUser</span><span class="p">);</span>
        <span class="n">DynamoDbTable</span><span class="p">.</span><span class="nf">GrantReadWriteData</span><span class="p">(</span><span class="n">lambdaRole</span><span class="p">);</span>
        
        <span class="c1">// --- Lambda Function: Bundling Strategy ---</span>
        <span class="c1">// Build vs Runtime split: We use .NET 9 SDK to build (latest tooling, optimizations),</span>
        <span class="c1">// but need to deploy to .NET 8 runtime (current latest AWS Lambda support with LTS, .NET 10 will arrive January 2026).</span>
        <span class="c1">// The bundle-lambda.sh script runs inside a .NET 9 container to:</span>
        <span class="c1">// 1. Install Amazon.Lambda.Tools CLI</span>
        <span class="c1">// 2. Run `dotnet lambda package` with Lambda-specific settings</span>
        <span class="c1">// 3. Output function.zip ready for deployment</span>
        <span class="kt">var</span> <span class="n">buildOption</span> <span class="p">=</span> <span class="k">new</span> <span class="n">BundlingOptions</span>
        <span class="p">{</span>
            <span class="n">Image</span> <span class="p">=</span> <span class="n">Runtime</span><span class="p">.</span><span class="n">DOTNET_9</span><span class="p">.</span><span class="n">BundlingImage</span><span class="p">,</span> <span class="c1">// Build-time: .NET 9 SDK</span>
            <span class="n">User</span> <span class="p">=</span> <span class="s">"root"</span><span class="p">,</span>
            <span class="n">OutputType</span> <span class="p">=</span> <span class="n">BundlingOutput</span><span class="p">.</span><span class="n">ARCHIVED</span><span class="p">,</span>
            <span class="n">Command</span> <span class="p">=</span> <span class="p">[</span><span class="s">"/bin/bash"</span><span class="p">,</span> <span class="s">"bundle-lambda.sh"</span><span class="p">],</span>
            <span class="n">BundlingFileAccess</span> <span class="p">=</span> <span class="n">BundlingFileAccess</span><span class="p">.</span><span class="n">VOLUME_COPY</span>
        <span class="p">};</span>
        
        <span class="c1">// Find solution root (bundle-lambda.sh expects to run from solution directory)</span>
        <span class="kt">var</span> <span class="n">solutionPath</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="nf">GetDirectoryName</span><span class="p">(</span><span class="n">Path</span><span class="p">.</span><span class="nf">GetDirectoryName</span><span class="p">(</span><span class="k">new</span> <span class="n">Projects</span><span class="p">.</span><span class="nf">Api</span><span class="p">().</span><span class="n">ProjectPath</span><span class="p">)!)!;</span>

        <span class="kt">var</span> <span class="n">lambda</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Function</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"Lambda"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">FunctionProps</span>
            <span class="p">{</span>
                <span class="n">FunctionName</span> <span class="p">=</span> <span class="s">"sample-lambda-function"</span><span class="p">,</span>
                <span class="n">Runtime</span> <span class="p">=</span> <span class="n">Runtime</span><span class="p">.</span><span class="n">DOTNET_8</span><span class="p">,</span> <span class="c1">// Runtime: .NET 8 (AWS Lambda support)</span>
                <span class="n">Handler</span> <span class="p">=</span> <span class="s">"Api"</span><span class="p">,</span> <span class="c1">// Assembly name (entry point)</span>
                <span class="n">Code</span> <span class="p">=</span> <span class="n">Code</span><span class="p">.</span><span class="nf">FromAsset</span><span class="p">(</span><span class="n">solutionPath</span><span class="p">,</span>
                    <span class="k">new</span> <span class="n">Amazon</span><span class="p">.</span><span class="n">CDK</span><span class="p">.</span><span class="n">AWS</span><span class="p">.</span><span class="n">S3</span><span class="p">.</span><span class="n">Assets</span><span class="p">.</span><span class="n">AssetOptions</span>
                    <span class="p">{</span>
                        <span class="n">Bundling</span> <span class="p">=</span> <span class="n">buildOption</span>
                    <span class="p">}),</span>
                <span class="n">Role</span> <span class="p">=</span> <span class="n">lambdaRole</span><span class="p">,</span>
                <span class="n">Vpc</span> <span class="p">=</span> <span class="n">vpc</span><span class="p">,</span>
                <span class="n">VpcSubnets</span> <span class="p">=</span> <span class="n">privateSubnets</span><span class="p">,</span>
                <span class="n">SecurityGroups</span> <span class="p">=</span> <span class="p">[</span><span class="n">lambdaSg</span><span class="p">],</span>
                <span class="n">MemorySize</span> <span class="p">=</span> <span class="m">512</span><span class="p">,</span>
                <span class="n">Timeout</span> <span class="p">=</span> <span class="n">Duration</span><span class="p">.</span><span class="nf">Seconds</span><span class="p">(</span><span class="m">10</span><span class="p">),</span>
                <span class="n">Environment</span> <span class="p">=</span> <span class="k">new</span> <span class="n">Dictionary</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">,</span> <span class="kt">string</span><span class="p">&gt;</span>
                <span class="p">{</span>
                    <span class="c1">// Lambda reads connection string from environment.</span>
                    <span class="c1">// Api/Program.cs uses this to configure Npgsql + IAM auth.</span>
                    <span class="p">[</span><span class="s">"ConnectionStrings__Postgres"</span><span class="p">]</span> <span class="p">=</span> <span class="n">PgConnectionString</span><span class="p">.</span><span class="n">Value</span><span class="p">.</span><span class="nf">ToString</span><span class="p">()!</span>
                <span class="p">}</span>
            <span class="p">});</span>

        <span class="c1">// --- API Gateway: Public HTTP Endpoint ---</span>
        <span class="c1">// HTTP API (v2) is simpler and cheaper than REST API (v1).</span>
        <span class="c1">// Catch-all route /{proxy+} forwards ALL requests to Lambda.</span>
        <span class="c1">// Lambda (ASP.NET Core) handles routing internally.</span>
        <span class="kt">var</span> <span class="n">httpApi</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">HttpApi</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"Api"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">HttpApiProps</span>
            <span class="p">{</span>
                <span class="n">ApiName</span> <span class="p">=</span> <span class="s">"api"</span><span class="p">,</span>
                <span class="n">Description</span> <span class="p">=</span> <span class="s">"HTTP API"</span>
            <span class="p">});</span>
        
        <span class="c1">// Catch-all integration: API Gateway doesn't need to know about routes.</span>
        <span class="c1">// /{proxy+} matches /users, /products/123, etc.</span>
        <span class="c1">// Lambda's ASP.NET Core app handles routing via controllers/endpoints.</span>
        <span class="n">httpApi</span><span class="p">.</span><span class="nf">AddRoutes</span><span class="p">(</span><span class="k">new</span> <span class="n">AddRoutesOptions</span>
        <span class="p">{</span>
            <span class="n">Path</span> <span class="p">=</span> <span class="s">"/{proxy+}"</span><span class="p">,</span>
            <span class="n">Methods</span> <span class="p">=</span> <span class="p">[</span><span class="n">Amazon</span><span class="p">.</span><span class="n">CDK</span><span class="p">.</span><span class="n">AWS</span><span class="p">.</span><span class="n">Apigatewayv2</span><span class="p">.</span><span class="n">HttpMethod</span><span class="p">.</span><span class="n">ANY</span><span class="p">],</span>
            <span class="n">Integration</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">HttpLambdaIntegration</span><span class="p">(</span><span class="s">"LambdaIntegration"</span><span class="p">,</span> <span class="n">lambda</span><span class="p">)</span>
        <span class="p">});</span>
        
        <span class="n">ApiUrl</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">CfnOutput</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"ApiUrl"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">CfnOutputProps</span>
            <span class="p">{</span>
                <span class="n">Value</span> <span class="p">=</span> <span class="n">httpApi</span><span class="p">.</span><span class="n">Url</span><span class="p">!,</span>
            <span class="p">});</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p><strong>Bundling Script Details:</strong> The <code class="language-plaintext highlighter-rouge">bundle-lambda.sh</code> script runs during <code class="language-plaintext highlighter-rouge">cdk deploy</code>. It installs <code class="language-plaintext highlighter-rouge">Amazon.Lambda.Tools</code>, runs <code class="language-plaintext highlighter-rouge">dotnet lambda package</code> with Lambda-specific optimizations, and outputs <code class="language-plaintext highlighter-rouge">function.zip</code> to <code class="language-plaintext highlighter-rouge">/asset-output/</code>. CDK uploads this zip to S3, then CloudFormation deploys it to Lambda.</p> <p><strong>Inheritance in Stacks</strong>: <code class="language-plaintext highlighter-rouge">SampleStack</code> inherits from <code class="language-plaintext highlighter-rouge">SampleBaseStack</code>. This allows us to share common resources (like the DynamoDB table) between local and production stacks, while adding production-specific resources (VPC, Aurora, Lambda, API Gateway) in the derived class. This reduces duplication and keeps shared definitions in one place. Alternatively, we can use concept of nested stacks. Nested stacks allow us to compose stacks within stacks, promoting reuse and modularity. However, for simplicity, inheritance suffices here. Nested stacks can be explored in more complex scenarios.</p> <h2 id="api-project-environment-aware-configuration">API Project: Environment-Aware Configuration</h2> <p>With the infrastructure defined, we need to make the application code aware of which environment it’s running in. The CDK stack creates the infrastructure, but the API itself needs to connect to the right services - LocalStack when running locally, real AWS when deployed.</p> <p>The <code class="language-plaintext highlighter-rouge">Program.cs</code> adapts to its environment using a simple config flag. <code class="language-plaintext highlighter-rouge">UseLocalStack</code> determines whether to use emulated services or real AWS (while it is possible to use only LocalStack configuration which will automatically fallback to real AWS services, but I prefer to be explicit here). Also, the Postgres connection uses either a static password (local) or IAM auth tokens (production) via a periodic password provider.</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetValue</span><span class="p">(</span><span class="s">"LocalStack:UseLocalStack"</span><span class="p">,</span> <span class="k">false</span><span class="p">))</span>
<span class="p">{</span>
    <span class="c1">// Local: Use LocalStack endpoints and static DB password</span>
    <span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddLocalStack</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">);</span>
    <span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddAWSServiceLocalStack</span><span class="p">&lt;</span><span class="n">IAmazonDynamoDB</span><span class="p">&gt;();</span>
    <span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddNpgsqlDataSource</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetConnectionString</span><span class="p">(</span><span class="s">"Postgres"</span><span class="p">)!);</span>
<span class="p">}</span>
<span class="k">else</span>
<span class="p">{</span>
    <span class="c1">// AWS: Use real AWS and IAM auth tokens for DB</span>
    <span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddAWSService</span><span class="p">&lt;</span><span class="n">IAmazonDynamoDB</span><span class="p">&gt;();</span>
    <span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddNpgsqlDataSource</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetConnectionString</span><span class="p">(</span><span class="s">"Postgres"</span><span class="p">)!,</span>
        <span class="n">b</span> <span class="p">=&gt;</span> <span class="p">{</span>
            <span class="n">b</span><span class="p">.</span><span class="nf">UsePeriodicPasswordProvider</span><span class="p">((</span><span class="n">cs</span><span class="p">,</span> <span class="n">_</span><span class="p">)</span> <span class="p">=&gt;</span>
                <span class="n">ValueTask</span><span class="p">.</span><span class="nf">FromResult</span><span class="p">(</span><span class="n">RDSAuthTokenGenerator</span><span class="p">.</span><span class="nf">GenerateAuthToken</span><span class="p">(</span><span class="n">cs</span><span class="p">.</span><span class="n">Host</span><span class="p">,</span> <span class="n">cs</span><span class="p">.</span><span class="n">Port</span><span class="p">,</span> <span class="n">cs</span><span class="p">.</span><span class="n">Username</span><span class="p">)),</span>
            <span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromMinutes</span><span class="p">(</span><span class="m">10</span><span class="p">),</span>  <span class="c1">// Refresh every 10 minutes</span>
            <span class="n">TimeSpan</span><span class="p">.</span><span class="nf">FromSeconds</span><span class="p">(</span><span class="m">5</span><span class="p">));</span>  <span class="c1">// Refresh after 5 seconds in case of failure</span>
        <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div> <p><strong>Why periodic password provider?</strong></p> <p>RDS Proxy with IAM authentication doesn’t use static passwords. Instead, the Lambda generates a temporary authentication token (valid for 15 minutes) signed with its IAM credentials. The <code class="language-plaintext highlighter-rouge">RDSAuthTokenGenerator.GenerateAuthToken</code> method creates this token on-demand using the Lambda’s IAM role.</p> <p>The periodic provider automatically handles token refresh:</p> <ul> <li>Generates a fresh token every 10 minutes (before the 15-minute expiry)</li> <li>Handles token refresh transparently - application code sees a normal connection, never knows tokens are being rotated</li> <li>Eliminates secrets management entirely (no passwords stored in environment variables, config files, or secrets managers)</li> </ul> <p>Locally, the Postgres container uses a static password from the connection string (simpler for development). Same application queries, different credential strategy.</p> <h2 id="deploying-to-aws">Deploying to AWS</h2> <p>Before deploying for the first time, we need to bootstrap CDK in AWS account and region. Bootstrapping is a one-time setup that creates:</p> <ul> <li>An S3 bucket to store CloudFormation templates and Lambda deployment packages</li> <li>IAM roles that allow CloudFormation to create resources on our behalf</li> <li>An ECR repository for Docker images (if needed)</li> </ul> <p>If <a href="https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html">AWS environment is set up</a> and <a href="https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html">cdk is installed</a>, we can just run the bootstrap command:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cdk bootstrap
</code></pre></div></div> <p>We only need to run this once per account/region combination. If we deploy to multiple regions (e.g., <code class="language-plaintext highlighter-rouge">us-east-1</code>, <code class="language-plaintext highlighter-rouge">eu-west-1</code>), bootstrap each one separately.</p> <p><strong>What happens during bootstrap:</strong></p> <ol> <li>CDK creates a CloudFormation stack named <code class="language-plaintext highlighter-rouge">CDKToolkit</code></li> <li>An S3 bucket (named like <code class="language-plaintext highlighter-rouge">cdk-hnb659fds-assets-ACCOUNT-REGION</code>) is created to store deployment artifacts</li> <li>IAM roles are created with permissions to deploy infrastructure</li> <li>The bootstrap stack is versioned, allowing CDK to upgrade its own infrastructure over time</li> </ol> <p><strong>Important</strong></p> <ul> <li>CDK requires <code class="language-plaintext highlighter-rouge">cdk.json</code> file to be present in the project root. If it is missing, it can be created with <code class="language-plaintext highlighter-rouge">cdk init app --language csharp</code> command and then copied to the Aspire Host project root.</li> <li><code class="language-plaintext highlighter-rouge">cdk.json</code> has to contain correct <code class="language-plaintext highlighter-rouge">app</code> command to run the Aspire Host project to get the CDK stack. Example content: <code class="language-plaintext highlighter-rouge">"app": "dotnet run -- --publisher manifest --output-path ./manifest.json"</code></li> </ul> <p>As long as bootstrap is done, we can deploy the stack. One command triggers publish mode:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cdk deploy <span class="nt">--outputs-file</span> ./cdk-outputs.json
</code></pre></div></div> <p>When we run the deployment command, here’s the step-by-step process:</p> <ol> <li><strong>Aspire evaluates resources in publish mode</strong> → <code class="language-plaintext highlighter-rouge">IsPublishMode = true</code>, so the publish branch runs</li> <li><strong>CDK synthesizes CloudFormation template</strong> → CDK compiles our C# infrastructure code into a CloudFormation template, saved in the <code class="language-plaintext highlighter-rouge">cdk.out/</code> directory along with any assets (like Lambda zip files)</li> <li><strong>Lambda code is bundled</strong> → A Docker container with the .NET SDK runs <code class="language-plaintext highlighter-rouge">bundle-lambda.sh</code>, which builds the Api project with Lambda-specific settings and packages it into a zip file</li> <li><strong>CDK deploys stack</strong> → CloudFormation creates all the resources: VPC (with isolated subnets, route tables, security groups), Aurora Serverless v2 cluster (with auto-scaling capacity), RDS Proxy (with IAM auth configuration), DynamoDB table, Lambda function (uploaded from the bundled zip), and HTTP API Gateway (with routing configuration)</li> <li><strong>Stack outputs are displayed</strong> → After deployment completes, CDK shows the outputs we defined: <code class="language-plaintext highlighter-rouge">ApiUrl</code> (the public endpoint to test) and <code class="language-plaintext highlighter-rouge">DatabaseConnectionString</code> (for diagnostics)</li> <li><strong>Stack outputs saved to file</strong> → The <code class="language-plaintext highlighter-rouge">--outputs-file</code> option saves the outputs to <code class="language-plaintext highlighter-rouge">cdk-outputs.json</code></li> </ol> <p>First deployment takes 5-10 minutes, mostly waiting for the Aurora cluster to provision. Subsequent updates are much faster (30-60 seconds) because CloudFormation only updates changed resources. If we only modify Lambda code, CloudFormation just updates the function, leaving the database and VPC untouched.</p> <p>After deployment, we can test the API by sending HTTP requests to the <code class="language-plaintext highlighter-rouge">ApiUrl</code> endpoint using Postman, curl, or a web browser.</p> <h2 id="why-this-pattern-works">Why This Pattern Works</h2> <ul> <li><strong>Single Language, Full Stack</strong>. Stay in C# for both application logic and infrastructure. We get IntelliSense, compile-time checks, and refactoring tools for infrastructure code - benefits we don’t get with YAML or HCL.</li> <li><strong>Dev/Prod Parity for Services</strong>. The same service <code class="language-plaintext highlighter-rouge">Program.cs</code> defines both environments. Only the <em>providers</em> change (LocalStack vs real AWS), not the topology. This reduces the probability of “works on my machine” issues.</li> <li><strong>Application-Centric Infrastructure</strong>. Infrastructure lives next to code. Adding a new service means: add project reference + add infra fragment in the same file. No separate IaC repo to keep in sync.</li> <li><strong>Simplified CI/CD</strong>. One command deploys everything. PRs can bundle code + infrastructure changes atomically. No risk of “infra merged, app not deployed” or vice versa.</li> </ul> <h2 id="current-limitations-and-future-direction">Current Limitations and Future Direction</h2> <p><strong>Why Not “Just Aspire Deploy” Today?</strong></p> <p>Aspire (as of late 2025) doesn’t have native AWS publishers that handle:</p> <ul> <li>Building and zipping .NET projects for Lambda runtime</li> <li>Modeling AWS services with first-class abstractions</li> <li>Modeling AWS-specific constructs, RDS Proxy, VPCs, VPC endpoints, IAM roles, etc.</li> <li>Advanced deployment strategies (blue/green, canary)</li> </ul> <p>AWS CDK already solves these problems. So the division of labor is:</p> <ul> <li>Aspire Orchestrates: decides mode, wires dependencies, manages service references</li> <li>CDK Provisions: synthesizes CloudFormation, bundles assets, deploys to AWS</li> </ul> <p><strong>What Could Improve</strong></p> <p>If Aspire evolves to include first-class resource abstractions for common AWS services (Aurora, DynamoDB, API Gateway, Lambda, etc.), then this pattern could simplify: fewer explicit CDK constructs, more declarative Aspire resource definitions. But we don’t need to wait for that future to ship productively today.</p> <p><strong>Good news</strong>: There’s active work happening in this space. AWS and the .NET team are collaborating on <a href="https://github.com/aws/integrations-on-dotnet-aspire-for-aws/issues/50">improving AWS integration with Aspire</a>. The initiative aims to create first-class AWS resource support, native deployment primitives, and streamlined workflows - exactly the kind of improvements outlined above.</p>]]></content><author><name>Nazarii Piontko</name></author><category term="dotnet"/><category term="aspire"/><category term="aws"/><category term="cdk"/><summary type="html"><![CDATA[Deploy .NET Aspire applications to AWS without maintaining separate infrastructure code. One C# project switches between local emulation and production. Aspire handles orchestration, AWS CDK handles provisioning.]]></summary></entry><entry><title type="html">Configuring .NET Aspire with AWS and LocalStack</title><link href="https://www.npiontko.pro/2025/11/03/aspire-localstack" rel="alternate" type="text/html" title="Configuring .NET Aspire with AWS and LocalStack"/><published>2025-11-03T00:00:00+00:00</published><updated>2025-11-03T00:00:00+00:00</updated><id>https://www.npiontko.pro/2025/11/03/aspire-localstack</id><content type="html" xml:base="https://www.npiontko.pro/2025/11/03/aspire-localstack"><![CDATA[<p>Developing a .NET application that uses AWS services presents several challenges. Either we’re constantly hitting real AWS and watching our bill climb, or we’re mocking everything and wondering if our code actually works. Teams often share dev environments, stepping on each other’s data and debugging sessions. Developers frequently get stuck waiting for the “dev environment” to be free while someone else is testing their integration. .NET Aspire helps with microservice orchestration, but it doesn’t solve the AWS development problem.</p> <p>That’s where <a href="https://www.localstack.cloud/">LocalStack</a> comes in. Think of it as AWS running on our computer - S3, Lambda, DynamoDB, SQS, SNS, etc. The community edition is free and covers almost everything needed for typical development. No more spinning up separate AWS environments for each developer or dealing with resource cleanup across multiple accounts. Plus, we get faster feedback loops, can work offline, and can reset entire environment with a simple container restart.</p> <p>In this article, we’ll build a practical example that many developers encounter: an API service that uploads files to Amazon S3 and serves them via static website hosting. Similar pattern appears everywhere: profile picture uploads, document storage, media galleries, etc. We’ll configure everything through .NET Aspire and LocalStack, so the same code works locally and in production without changes.</p> <p>Before diving into the configuration, let’s make sure our starting point is clear. We have a .NET Aspire application with one API service. The service exposes HTTP endpoints. Our solution structure looks like this (standard Aspire layout):</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- AppHost - the Aspire host project
  - Program.cs - Aspire host entry point
  - appsettings.json - Aspire host configuration file
  - other Aspire host files...
- Api
  - Program.cs - API service entry point
  - appsettings.json - API service configuration file
  - other API service files...
- ServiceDefaults - common service configuration
  - Extensions.cs - common service extensions
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">AppHost/Program.cs</code>:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">DistributedApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="n">AddProject</span><span class="p">&lt;</span><span class="n">Projects</span><span class="p">.</span><span class="n">Api</span><span class="p">&gt;(</span><span class="s">"api"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithHttpHealthCheck</span><span class="p">(</span><span class="s">"/health"</span><span class="p">);</span>

<span class="k">await</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">().</span><span class="nf">RunAsync</span><span class="p">();</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">Api/Program.cs</code>:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="nf">AddServiceDefaults</span><span class="p">();</span>

<span class="kt">var</span> <span class="n">app</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">MapDefaultEndpoints</span><span class="p">();</span>

<span class="c1">// Health check endpoint</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapGet</span><span class="p">(</span><span class="s">"/health"</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="s">"Healthy"</span><span class="p">);</span>

<span class="n">app</span><span class="p">.</span><span class="nf">Run</span><span class="p">();</span>
</code></pre></div></div> <p>Having this structure in place, we can now enhance the AppHost to integrate LocalStack and AWS services. First, we need to add the necessary Aspire LocalStack package <code class="language-plaintext highlighter-rouge">LocalStack.Aspire.Hosting</code> to AppHost project via NuGet. In the <code class="language-plaintext highlighter-rouge">AppHost.csproj</code> file this looks like this:</p> <div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"LocalStack.Aspire.Hosting"</span> <span class="nt">/&gt;</span>
</code></pre></div></div> <p>This package includes everything: LocalStack container management, AWS CDK integration, and CloudFormation orchestration.</p> <p>Next, we need to configure the AWS SDK by adding a shared AWS configuration context:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">awsConfig</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSSDKConfig</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">WithProfile</span><span class="p">(</span><span class="s">"default"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithRegion</span><span class="p">(</span><span class="n">RegionEndpoint</span><span class="p">.</span><span class="n">EUCentral1</span><span class="p">);</span>
</code></pre></div></div> <p>The profile leverages AWS credential chains - local development can use AWS CLI profiles, while production uses IAM roles. The region specification ensures correct endpoints. We can make this more flexible with configuration:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">awsConfig</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSSDKConfig</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">WithProfile</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">[</span><span class="s">"AWS:Profile"</span><span class="p">]</span> <span class="p">??</span> <span class="s">"default"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithRegion</span><span class="p">(</span><span class="n">RegionEndpoint</span><span class="p">.</span><span class="nf">GetBySystemName</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">[</span><span class="s">"AWS:Region"</span><span class="p">]</span> <span class="p">??</span> <span class="s">"eu-central-1"</span><span class="p">));</span>
</code></pre></div></div> <p>Next, we need to set up LocalStack itself. We’ll tell Aspire to manage a LocalStack container for us, configure its lifetime, and set logging levels for debugging:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">awsLocal</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddLocalStack</span><span class="p">(</span><span class="s">"aws-local"</span><span class="p">,</span> <span class="c1">// Name of the LocalStack instance</span>
    <span class="n">awsConfig</span><span class="p">:</span> <span class="n">awsConfig</span><span class="p">,</span> <span class="c1">// Link to AWS config created above</span>
    <span class="n">configureContainer</span><span class="p">:</span> <span class="n">c</span> <span class="p">=&gt;</span> <span class="c1">// Configure LocalStack container options</span>
    <span class="p">{</span>
        <span class="n">c</span><span class="p">.</span><span class="n">Lifetime</span> <span class="p">=</span> <span class="n">ContainerLifetime</span><span class="p">.</span><span class="n">Session</span><span class="p">;</span> <span class="c1">// Reset container on app restart</span>
        <span class="n">c</span><span class="p">.</span><span class="n">DebugLevel</span> <span class="p">=</span> <span class="m">1</span><span class="p">;</span> <span class="c1">// Enable detailed logging</span>
        <span class="n">c</span><span class="p">.</span><span class="n">LogLevel</span> <span class="p">=</span> <span class="n">LocalStackLogLevel</span><span class="p">.</span><span class="n">Debug</span><span class="p">;</span> <span class="c1">// Show debug information</span>
    <span class="p">});</span>
</code></pre></div></div> <p>Key settings here:</p> <ul> <li><strong>Lifetime</strong>: <ul> <li><code class="language-plaintext highlighter-rouge">Session</code>: container resets every time we restart the app (default for testing)</li> <li><code class="language-plaintext highlighter-rouge">Persistent</code>: container survives app restarts, keeping our data</li> </ul> </li> <li><strong>LogLevel</strong>: see exactly what’s happening with AWS calls</li> <li><strong>DebugLevel</strong>: <code class="language-plaintext highlighter-rouge">1</code> - detailed request/response logging for troubleshooting</li> </ul> <p>The persistent lifetime is important; we don’t want to recreate S3 buckets every time we restart debugging. While Session lifetime is useful for testing setups, Persistent lifetime is better for day-to-day development.</p> <p>The important part here is the AppHost configuration file. <code class="language-plaintext highlighter-rouge">AddLocalStack</code> will use the <code class="language-plaintext highlighter-rouge">LocalStack:UseLocalStack</code> setting to determine whether it should run in local mode. It looks for this setting in our configuration (<code class="language-plaintext highlighter-rouge">appsettings.json</code>, environment variable <code class="language-plaintext highlighter-rouge">LocalStack__UseLocalStack</code>, etc.). So in <code class="language-plaintext highlighter-rouge">AppHost/appsettings.json</code>, we can add:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"LocalStack"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"UseLocalStack"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>Without this setting, LocalStack integration is disabled, and our services will try to connect to real AWS. This approach makes it easy to switch between local development and production without code changes.</p> <p>After that, we can start defining AWS infrastructure. We can define AWS infrastructure as code and have it work with both LocalStack and real AWS. We’ll use the AWS CDK (Cloud Development Kit) for this. Think of it as a way to write our infrastructure using familiar programming languages instead of YAML or JSON. CDK lets us define AWS resources like S3 buckets, SQS queues, or Lambda functions using C# classes, complete with IntelliSense, type safety, and all the benefits of a real programming language. When we build our CDK code, it generates CloudFormation templates that AWS (or LocalStack) can deploy.</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">awsStack</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSCDKStack</span><span class="p">(</span><span class="s">"aws-stack"</span><span class="p">,</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">AwsStack</span><span class="p">(</span><span class="n">s</span><span class="p">))</span>
    <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">awsConfig</span><span class="p">);</span> <span class="c1">// Link to AWS configuration</span>

<span class="kt">var</span> <span class="n">awsStackOutputs</span> <span class="p">=</span> <span class="n">AwsStackOutputs</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="n">awsStack</span><span class="p">);</span> <span class="c1">// Get typed access to stack outputs</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">AwsStack</code> is a custom class where we define AWS resources. To define the actual infrastructure, here’s how we can create an Amazon S3 bucket with website hosting:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">sealed</span> <span class="k">class</span> <span class="nc">AwsStack</span> <span class="p">:</span> <span class="n">Amazon</span><span class="p">.</span><span class="n">CDK</span><span class="p">.</span><span class="n">Stack</span>
<span class="p">{</span>
    <span class="c1">// Expose the S3 bucket as a property for output references</span>
    <span class="k">public</span> <span class="n">IBucket</span> <span class="n">Bucket</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="k">public</span> <span class="nf">AwsStack</span><span class="p">(</span><span class="n">Constructs</span><span class="p">.</span><span class="n">Construct</span> <span class="n">scope</span><span class="p">)</span>
        <span class="p">:</span> <span class="k">base</span><span class="p">(</span><span class="n">scope</span><span class="p">,</span> <span class="s">"bucket"</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="c1">// Create S3 bucket with website hosting enabled</span>
        <span class="n">Bucket</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Bucket</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"bucket"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">BucketProps</span>
            <span class="p">{</span>
                <span class="n">BucketName</span> <span class="p">=</span> <span class="s">"test-data-bucket"</span><span class="p">,</span>
                <span class="n">WebsiteIndexDocument</span> <span class="p">=</span> <span class="s">"index.html"</span> <span class="c1">// Enable static website hosting</span>
            <span class="p">});</span>

        <span class="c1">// Allow public read access for static website hosting</span>
        <span class="n">Bucket</span><span class="p">.</span><span class="nf">AddToResourcePolicy</span><span class="p">(</span><span class="k">new</span> <span class="nf">PolicyStatement</span><span class="p">(</span><span class="k">new</span> <span class="n">PolicyStatementProps</span>
        <span class="p">{</span>
            <span class="n">Actions</span> <span class="p">=</span> <span class="p">[</span><span class="s">"s3:GetObject"</span><span class="p">],</span> <span class="c1">// Allow public read access to objects</span>
            <span class="n">Effect</span> <span class="p">=</span> <span class="n">Effect</span><span class="p">.</span><span class="n">ALLOW</span><span class="p">,</span> <span class="c1">// Allow access</span>
            <span class="n">Principals</span> <span class="p">=</span> <span class="p">[</span><span class="k">new</span> <span class="nf">AnyPrincipal</span><span class="p">()],</span> <span class="c1">// Anyone can access</span>
            <span class="n">Resources</span> <span class="p">=</span> <span class="p">[</span><span class="n">Bucket</span><span class="p">.</span><span class="nf">ArnForObjects</span><span class="p">(</span><span class="s">"*"</span><span class="p">)]</span> <span class="c1">// All objects in the bucket</span>
        <span class="p">}));</span>
    <span class="p">}</span>
    
<span class="p">}</span>

<span class="c1">// The AwsStackOutputs class provides type-safe access to stack resources.</span>
<span class="c1">// Instead of handling raw strings in Program.cs, this gives compile-time validation</span>
<span class="c1">// and prevents typos from becoming runtime errors.</span>
<span class="k">public</span> <span class="k">sealed</span> <span class="k">class</span> <span class="nc">AwsStackOutputs</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IResourceBuilder</span><span class="p">&lt;</span><span class="n">IStackResource</span><span class="p">&lt;</span><span class="n">AwsStack</span><span class="p">&gt;&gt;</span> <span class="n">_stack</span><span class="p">;</span>

    <span class="k">private</span> <span class="nf">AwsStackOutputs</span><span class="p">(</span><span class="n">IResourceBuilder</span><span class="p">&lt;</span><span class="n">IStackResource</span><span class="p">&lt;</span><span class="n">AwsStack</span><span class="p">&gt;&gt;</span> <span class="n">stack</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_stack</span> <span class="p">=</span> <span class="n">stack</span><span class="p">;</span>
    <span class="p">}</span>
        
    <span class="c1">// Output reference for the bucket name</span>
    <span class="k">public</span> <span class="n">StackOutputReference</span> <span class="n">BucketName</span> <span class="p">=&gt;</span> <span class="n">_stack</span><span class="p">.</span><span class="nf">GetOutput</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="n">BucketName</span><span class="p">));</span>
    
    <span class="c1">// Output reference for the bucket website URL</span>
    <span class="k">public</span> <span class="n">StackOutputReference</span> <span class="n">BucketWebsiteUrl</span> <span class="p">=&gt;</span> <span class="n">_stack</span><span class="p">.</span><span class="nf">GetOutput</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="n">BucketWebsiteUrl</span><span class="p">));</span>

    <span class="c1">// Factory method to create AwsStackOutputs and register outputs</span>
    <span class="k">public</span> <span class="k">static</span> <span class="n">AwsStackOutputs</span> <span class="nf">Create</span><span class="p">(</span><span class="n">IResourceBuilder</span><span class="p">&lt;</span><span class="n">IStackResource</span><span class="p">&lt;</span><span class="n">AwsStack</span><span class="p">&gt;&gt;</span> <span class="n">stack</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">stack</span><span class="p">.</span><span class="nf">AddOutput</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="n">BucketName</span><span class="p">),</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="n">Bucket</span><span class="p">.</span><span class="n">BucketName</span><span class="p">);</span>
        <span class="n">stack</span><span class="p">.</span><span class="nf">AddOutput</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="n">BucketWebsiteUrl</span><span class="p">),</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="n">Bucket</span><span class="p">.</span><span class="n">BucketWebsiteUrl</span><span class="p">);</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">AwsStackOutputs</span><span class="p">(</span><span class="n">stack</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>The bucket configuration sets up static website hosting with public read access. The resource policy allows public access to objects while keeping the bucket itself private. Similarly, we can define other AWS resources as needed. For example, for Amazon SQS queues, we can call <code class="language-plaintext highlighter-rouge">new Queue(this, "queue", new QueueProps { ... })</code> and expose outputs like <code class="language-plaintext highlighter-rouge">QueueUrl</code> and/or <code class="language-plaintext highlighter-rouge">QueueArn</code>. For Amazon DynamoDB tables, we can define <code class="language-plaintext highlighter-rouge">new Table(this, "table", new TableProps { ... })</code> and expose outputs like <code class="language-plaintext highlighter-rouge">TableName</code>.</p> <p>Once the infrastructure is defined, we can reference these resources in our services. Here’s how to configure an API service to use the Amazon S3 bucket we created:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">var</span> <span class="n">apiService</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="n">AddProject</span><span class="p">&lt;</span><span class="n">Projects</span><span class="p">.</span><span class="n">ApiService</span><span class="p">&gt;(</span><span class="s">"api"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithHttpHealthCheck</span><span class="p">(</span><span class="s">"/health"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">awsStack</span><span class="p">)</span> <span class="c1">// Reference the AWS stack for automatic configuration</span>
    <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"Storage__BucketName"</span><span class="p">,</span> <span class="n">awsStackOutputs</span><span class="p">.</span><span class="n">BucketName</span><span class="p">)</span> <span class="c1">// Inject bucket name as config</span>
    <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"Storage__PublicBaseUrl"</span><span class="p">,</span> <span class="n">awsStackOutputs</span><span class="p">.</span><span class="n">BucketWebsiteUrl</span><span class="p">);</span> <span class="c1">// Inject bucket URL as config</span>
</code></pre></div></div> <p>The <code class="language-plaintext highlighter-rouge">WithEnvironment(...)</code> calls inject these values as environment variables. <code class="language-plaintext highlighter-rouge">Storage__BucketName</code> becomes <code class="language-plaintext highlighter-rouge">Storage:BucketName</code> in our service’s configuration.</p> <p>It’s also important to note that <code class="language-plaintext highlighter-rouge">WithReference(awsStack)</code> will automatically inject AWS resource outputs under the <code class="language-plaintext highlighter-rouge">AWS__Resources__*</code> configuration section, so we can access them directly as well. However, providing explicit configuration like <code class="language-plaintext highlighter-rouge">Storage:BucketName</code> in the service is often preferable to relying on generic output access. This approach also decouples our service configuration from the underlying infrastructure implementation, making it easier to change infrastructure without affecting service code.</p> <p>Just before building the host, we need to enable LocalStack integration:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Enable LocalStack integration for all services which reference AWS resources</span>
<span class="n">builder</span><span class="p">.</span><span class="nf">UseLocalStack</span><span class="p">(</span><span class="n">awsLocal</span><span class="p">);</span>
</code></pre></div></div> <p>This one line does a lot of heavy lifting. It configures all AWS resources in the application to use the specified LocalStack instance, automatically detects CloudFormation templates and CDK stacks, and handles CDK bootstrap if needed. This method scans all resources in the application and automatically configures AWS resources and projects that reference AWS resources to use LocalStack for local development. It:</p> <ul> <li>Detects all CloudFormation templates and CDK stack resources</li> <li>Creates a CDK bootstrap resource automatically if CDK stacks are present</li> <li>Configures all AWS resources to use LocalStack endpoints</li> <li>Sets up proper dependency ordering for CDK bootstrap</li> <li>Automatically configures projects that reference AWS resources</li> <li>Adds annotation tracking to prevent duplicate configuration</li> </ul> <p>Here’s where it gets really interesting. When we call <code class="language-plaintext highlighter-rouge">WithReference(awsStack)</code> and enable LocalStack, Aspire automatically injects configuration into our services. We don’t have to manage any of this ourselves.</p> <p>Our service gets all these environment variables automatically:</p> <p><strong>LocalStack Configuration (Example)</strong>:</p> <div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">LocalStack__UseLocalStack</span> <span class="o">=</span><span class="w"> </span><span class="err">True
LocalStack__Config__EdgePort = </span><span class="mi">33895</span>
<span class="n">LocalStack__Config__LocalStackHost</span> <span class="o">=</span><span class="w"> </span><span class="err">localhost
LocalStack__Config__UseLegacyPorts = False
LocalStack__Config__UseSsl = False
LocalStack__Session__AwsAccessKey = secretKey
LocalStack__Session__AwsAccessKeyId = accessKey
LocalStack__Session__AwsSessionToken = token
LocalStack__Session__RegionName = eu-central</span><span class="mi">-1</span>
</code></pre></div></div> <p><strong>AWS Resource References (Example)</strong>:</p> <div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">AWS__Resources__ProfileBucketName</span> <span class="o">=</span><span class="w"> </span><span class="err">test-data-bucket
AWS__Resources__ProfileBucketWebsiteUrl = http://test-data-bucket.s</span><span class="mi">3</span><span class="n">-website</span><span class="p">.</span><span class="n">localhost</span><span class="err">:</span><span class="n">33895</span>
</code></pre></div></div> <p><strong>Application Configuration (Example)</strong>:</p> <div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Storage__BucketName</span> <span class="o">=</span><span class="w"> </span><span class="err">test-data-bucket
Storage__PublicBaseUrl = http://test-data-bucket.s</span><span class="mi">3</span><span class="n">-website</span><span class="p">.</span><span class="n">localhost</span><span class="err">:</span><span class="n">33895</span>
</code></pre></div></div> <p>This maps to a equivalent JSON configuration structure:</p> <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"LocalStack"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"UseLocalStack"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
    </span><span class="nl">"Config"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"EdgePort"</span><span class="p">:</span><span class="w"> </span><span class="mi">33895</span><span class="p">,</span><span class="w">
      </span><span class="nl">"LocalStackHost"</span><span class="p">:</span><span class="w"> </span><span class="s2">"localhost"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"UseLegacyPorts"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
      </span><span class="nl">"UseSsl"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"Session"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"AwsAccessKey"</span><span class="p">:</span><span class="w"> </span><span class="s2">"secretKey"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"AwsAccessKeyId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"accessKey"</span><span class="p">,</span><span class="w"> 
      </span><span class="nl">"AwsSessionToken"</span><span class="p">:</span><span class="w"> </span><span class="s2">"token"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"RegionName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"eu-central-1"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"AWS"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"Resources"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"ProfileBucketName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"test-data-bucket"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"ProfileBucketWebsiteUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://test-data-bucket.s3-website.localhost:33895"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
    </span><span class="nl">"Storage"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"BucketName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"test-data-bucket"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"PublicBaseUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"http://test-data-bucket.s3-website.localhost:33895"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div> <p>It’s also interesting that LocalStack handles URL rewriting for Amazon S3 website endpoints automatically. When we access, for example, <code class="language-plaintext highlighter-rouge">http://test-data-bucket.s3-website.localhost:33895</code>, LocalStack knows to route this to the correct S3 bucket in the LocalStack container. So if application code uploads an object to S3 and then constructs a URL using the bucket’s website URL, it will work seamlessly with LocalStack without any additional configuration. Similarly, we can use Amazon CloudFront in front of S3 by creating a <a href="https://aws-cdk.com/deploying-a-static-website-using-s3-and-cloudfront">Distribution object</a>, and that will work with LocalStack as well.</p> <p>So, after all this setup, the final version of <code class="language-plaintext highlighter-rouge">Program.cs</code> in our AppHost might looks like this:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">using</span> <span class="nn">Amazon</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.CDK.AWS.IAM</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.CDK.AWS.S3</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Aspire.Hosting.AWS.CDK</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Aspire.Hosting.AWS.CloudFormation</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Aspire.Hosting.LocalStack.Container</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">DistributedApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="c1">// AWS Resources</span>

<span class="kt">var</span> <span class="n">awsConfig</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSSDKConfig</span><span class="p">()</span>
    <span class="p">.</span><span class="nf">WithProfile</span><span class="p">(</span><span class="s">"default"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithRegion</span><span class="p">(</span><span class="n">RegionEndpoint</span><span class="p">.</span><span class="n">EUCentral1</span><span class="p">);</span>

<span class="kt">var</span> <span class="n">awsLocal</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddLocalStack</span><span class="p">(</span><span class="s">"aws-local"</span><span class="p">,</span>
    <span class="n">awsConfig</span><span class="p">:</span> <span class="n">awsConfig</span><span class="p">,</span>
    <span class="n">configureContainer</span><span class="p">:</span> <span class="n">c</span> <span class="p">=&gt;</span>
    <span class="p">{</span>
        <span class="n">c</span><span class="p">.</span><span class="n">Lifetime</span> <span class="p">=</span> <span class="n">ContainerLifetime</span><span class="p">.</span><span class="n">Session</span><span class="p">;</span>
        <span class="n">c</span><span class="p">.</span><span class="n">DebugLevel</span> <span class="p">=</span> <span class="m">1</span><span class="p">;</span>
        <span class="n">c</span><span class="p">.</span><span class="n">LogLevel</span> <span class="p">=</span> <span class="n">LocalStackLogLevel</span><span class="p">.</span><span class="n">Debug</span><span class="p">;</span>
    <span class="p">});</span>

<span class="kt">var</span> <span class="n">awsStack</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">AddAWSCDKStack</span><span class="p">(</span><span class="s">"aws-stack"</span><span class="p">,</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="k">new</span> <span class="nf">AwsStack</span><span class="p">(</span><span class="n">s</span><span class="p">))</span>
    <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">awsConfig</span><span class="p">);</span>
<span class="kt">var</span> <span class="n">awsStackOutputs</span> <span class="p">=</span> <span class="n">AwsStackOutputs</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="n">awsStack</span><span class="p">);</span>

<span class="c1">// Services</span>

<span class="n">builder</span><span class="p">.</span><span class="n">AddProject</span><span class="p">&lt;</span><span class="n">Projects</span><span class="p">.</span><span class="n">Api</span><span class="p">&gt;(</span><span class="s">"api"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithHttpHealthCheck</span><span class="p">(</span><span class="s">"/health"</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithReference</span><span class="p">(</span><span class="n">awsStack</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"Storage__BucketName"</span><span class="p">,</span> <span class="n">awsStackOutputs</span><span class="p">.</span><span class="n">BucketName</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">WithEnvironment</span><span class="p">(</span><span class="s">"Storage__PublicBaseUrl"</span><span class="p">,</span> <span class="n">awsStackOutputs</span><span class="p">.</span><span class="n">BucketWebsiteUrl</span><span class="p">);</span>

<span class="c1">// Run</span>

<span class="n">builder</span><span class="p">.</span><span class="nf">UseLocalStack</span><span class="p">(</span><span class="n">awsLocal</span><span class="p">);</span>

<span class="k">await</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">().</span><span class="nf">RunAsync</span><span class="p">();</span>

<span class="k">public</span> <span class="k">sealed</span> <span class="k">class</span> <span class="nc">AwsStack</span> <span class="p">:</span> <span class="n">Amazon</span><span class="p">.</span><span class="n">CDK</span><span class="p">.</span><span class="n">Stack</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="n">IBucket</span> <span class="n">Bucket</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="p">}</span>

    <span class="k">public</span> <span class="nf">AwsStack</span><span class="p">(</span><span class="n">Constructs</span><span class="p">.</span><span class="n">Construct</span> <span class="n">scope</span><span class="p">)</span>
        <span class="p">:</span> <span class="k">base</span><span class="p">(</span><span class="n">scope</span><span class="p">,</span> <span class="s">"bucket"</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">Bucket</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Bucket</span><span class="p">(</span><span class="k">this</span><span class="p">,</span>
            <span class="s">"bucket"</span><span class="p">,</span>
            <span class="k">new</span> <span class="n">BucketProps</span>
            <span class="p">{</span>
                <span class="n">BucketName</span> <span class="p">=</span> <span class="s">"test-data-bucket"</span><span class="p">,</span>
                <span class="n">WebsiteIndexDocument</span> <span class="p">=</span> <span class="s">"index.html"</span> <span class="c1">// Enable static website hosting</span>
            <span class="p">});</span>

        <span class="n">Bucket</span><span class="p">.</span><span class="nf">AddToResourcePolicy</span><span class="p">(</span><span class="k">new</span> <span class="nf">PolicyStatement</span><span class="p">(</span><span class="k">new</span> <span class="n">PolicyStatementProps</span>
        <span class="p">{</span>
            <span class="n">Actions</span> <span class="p">=</span> <span class="p">[</span><span class="s">"s3:GetObject"</span><span class="p">],</span>
            <span class="n">Effect</span> <span class="p">=</span> <span class="n">Effect</span><span class="p">.</span><span class="n">ALLOW</span><span class="p">,</span>
            <span class="n">Principals</span> <span class="p">=</span> <span class="p">[</span><span class="k">new</span> <span class="nf">AnyPrincipal</span><span class="p">()],</span>
            <span class="n">Resources</span> <span class="p">=</span> <span class="p">[</span><span class="n">Bucket</span><span class="p">.</span><span class="nf">ArnForObjects</span><span class="p">(</span><span class="s">"*"</span><span class="p">)]</span>
        <span class="p">}));</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">public</span> <span class="k">sealed</span> <span class="k">class</span> <span class="nc">AwsStackOutputs</span>
<span class="p">{</span>
    <span class="k">private</span> <span class="k">readonly</span> <span class="n">IResourceBuilder</span><span class="p">&lt;</span><span class="n">IStackResource</span><span class="p">&lt;</span><span class="n">AwsStack</span><span class="p">&gt;&gt;</span> <span class="n">_stack</span><span class="p">;</span>

    <span class="k">private</span> <span class="nf">AwsStackOutputs</span><span class="p">(</span><span class="n">IResourceBuilder</span><span class="p">&lt;</span><span class="n">IStackResource</span><span class="p">&lt;</span><span class="n">AwsStack</span><span class="p">&gt;&gt;</span> <span class="n">stack</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_stack</span> <span class="p">=</span> <span class="n">stack</span><span class="p">;</span>
    <span class="p">}</span>
        
    <span class="k">public</span> <span class="n">StackOutputReference</span> <span class="n">BucketName</span> <span class="p">=&gt;</span> <span class="n">_stack</span><span class="p">.</span><span class="nf">GetOutput</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="n">BucketName</span><span class="p">));</span>
    
    <span class="k">public</span> <span class="n">StackOutputReference</span> <span class="n">BucketWebsiteUrl</span> <span class="p">=&gt;</span> <span class="n">_stack</span><span class="p">.</span><span class="nf">GetOutput</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="n">BucketWebsiteUrl</span><span class="p">));</span>

    <span class="k">public</span> <span class="k">static</span> <span class="n">AwsStackOutputs</span> <span class="nf">Create</span><span class="p">(</span><span class="n">IResourceBuilder</span><span class="p">&lt;</span><span class="n">IStackResource</span><span class="p">&lt;</span><span class="n">AwsStack</span><span class="p">&gt;&gt;</span> <span class="n">stack</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">stack</span><span class="p">.</span><span class="nf">AddOutput</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="n">BucketName</span><span class="p">),</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="n">Bucket</span><span class="p">.</span><span class="n">BucketName</span><span class="p">);</span>
        <span class="n">stack</span><span class="p">.</span><span class="nf">AddOutput</span><span class="p">(</span><span class="k">nameof</span><span class="p">(</span><span class="n">BucketWebsiteUrl</span><span class="p">),</span> <span class="n">s</span> <span class="p">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="n">Bucket</span><span class="p">.</span><span class="n">BucketWebsiteUrl</span><span class="p">);</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">AwsStackOutputs</span><span class="p">(</span><span class="n">stack</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>And now when we run the AppHost, Aspire will start LocalStack, deploy CDK stack to it, and inject all the necessary configuration into API service. Here is a screenshot of the Aspire dashboard showing LocalStack running and the CDK stack deployed:</p> <p><img src="/assets/posts/aspire-localstack/dashboard.png" alt="Aspire Dashboard with LocalStack"/></p> <p>Now the AppHost is configured. It’s time to set up the service itself to use the AWS SDK with LocalStack. First, add the necessary package reference to the service: <code class="language-plaintext highlighter-rouge">LocalStack.Client</code>, <code class="language-plaintext highlighter-rouge">LocalStack.Client.Extensions</code>, <code class="language-plaintext highlighter-rouge">AWSSDK.S3</code>. In <code class="language-plaintext highlighter-rouge">Api.csproj</code> file, this looks like this:</p> <div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"AWSSDK.S3"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"LocalStack.Client"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;PackageReference</span> <span class="na">Include=</span><span class="s">"LocalStack.Client.Extensions"</span> <span class="nt">/&gt;</span>
</code></pre></div></div> <p>Then, in the API service <code class="language-plaintext highlighter-rouge">Program.cs</code>, we add LocalStack integration and register the S3 client:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Configure LocalStack and AWS SDK integration</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddLocalStack</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">);</span> <span class="c1">// Read LocalStack config from Aspire</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddDefaultAWSOptions</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetAWSOptions</span><span class="p">());</span> <span class="c1">// Configure AWS SDK</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddAwsService</span><span class="p">&lt;</span><span class="n">IAmazonS3</span><span class="p">&gt;();</span> <span class="c1">// Register S3 client with dependency injection</span>
</code></pre></div></div> <p>That’s it. The <code class="language-plaintext highlighter-rouge">AddLocalStack(...)</code> method reads all the configuration Aspire injected and sets up the AWS SDK to talk to LocalStack. The S3 client now points to LocalStack automatically. In addition, the AWS SDK client registration <code class="language-plaintext highlighter-rouge">AddAwsService&lt;IAmazonS3&gt;()</code> uses the configured AWS options, which now include LocalStack endpoints.</p> <p>Here’s how the simple API service code looks (we have two endpoints: one for uploading files to Amazon S3 and another for downloading files from S3; this is simple test functionality to demonstrate S3 integration):</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// SIMPLIFIED TESTING CODE - DO NOT USE IN PRODUCTION</span>
<span class="c1">// THIS IS FOR INTEGRATION DEMONSTRATION PURPOSES ONLY</span>

<span class="k">using</span> <span class="nn">Amazon.S3</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Amazon.S3.Model</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">LocalStack.Client.Extensions</span><span class="p">;</span>
<span class="k">using</span> <span class="nn">Microsoft.Extensions.Options</span><span class="p">;</span>

<span class="kt">var</span> <span class="n">builder</span> <span class="p">=</span> <span class="n">WebApplication</span><span class="p">.</span><span class="nf">CreateBuilder</span><span class="p">(</span><span class="n">args</span><span class="p">);</span>

<span class="n">builder</span><span class="p">.</span><span class="nf">AddServiceDefaults</span><span class="p">();</span>

<span class="c1">// Configure LocalStack and AWS SDK</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddLocalStack</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">);</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="nf">AddDefaultAWSOptions</span><span class="p">(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetAWSOptions</span><span class="p">());</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">AddAwsService</span><span class="p">&lt;</span><span class="n">IAmazonS3</span><span class="p">&gt;();</span>

<span class="c1">// Register our storage configuration</span>
<span class="n">builder</span><span class="p">.</span><span class="n">Services</span><span class="p">.</span><span class="n">Configure</span><span class="p">&lt;</span><span class="n">StorageOptions</span><span class="p">&gt;(</span><span class="n">builder</span><span class="p">.</span><span class="n">Configuration</span><span class="p">.</span><span class="nf">GetSection</span><span class="p">(</span><span class="s">"Storage"</span><span class="p">));</span>

<span class="kt">var</span> <span class="n">app</span> <span class="p">=</span> <span class="n">builder</span><span class="p">.</span><span class="nf">Build</span><span class="p">();</span>

<span class="n">app</span><span class="p">.</span><span class="nf">MapDefaultEndpoints</span><span class="p">();</span>

<span class="c1">// Health check endpoint</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapGet</span><span class="p">(</span><span class="s">"/health"</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="s">"Healthy"</span><span class="p">);</span>

<span class="c1">// Upload endpoint</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapPost</span><span class="p">(</span><span class="s">"/upload"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span>
    <span class="n">IFormFile</span> <span class="n">file</span><span class="p">,</span>
    <span class="n">IAmazonS3</span> <span class="n">s3Client</span><span class="p">,</span>
    <span class="n">IOptions</span><span class="p">&lt;</span><span class="n">StorageOptions</span><span class="p">&gt;</span> <span class="n">storageOptions</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">bucketName</span> <span class="p">=</span> <span class="n">storageOptions</span><span class="p">.</span><span class="n">Value</span><span class="p">.</span><span class="n">BucketName</span><span class="p">;</span>

    <span class="c1">// Use original filename as S3 key for simplicity (UNSAFE for production)</span>
    <span class="kt">var</span> <span class="n">key</span> <span class="p">=</span> <span class="n">file</span><span class="p">.</span><span class="n">FileName</span><span class="p">;</span>

    <span class="c1">// Open the input file stream</span>
    <span class="k">await</span> <span class="k">using</span> <span class="nn">var</span> <span class="n">stream</span> <span class="p">=</span> <span class="n">file</span><span class="p">.</span><span class="nf">OpenReadStream</span><span class="p">();</span>

    <span class="c1">// Upload the file to S3</span>
    <span class="k">await</span> <span class="n">s3Client</span><span class="p">.</span><span class="nf">PutObjectAsync</span><span class="p">(</span><span class="k">new</span> <span class="n">PutObjectRequest</span>
    <span class="p">{</span>
        <span class="n">BucketName</span> <span class="p">=</span> <span class="n">bucketName</span><span class="p">,</span>
        <span class="n">Key</span> <span class="p">=</span> <span class="n">key</span><span class="p">,</span>
        <span class="n">InputStream</span> <span class="p">=</span> <span class="n">stream</span><span class="p">,</span>
        <span class="n">ContentType</span> <span class="p">=</span> <span class="n">file</span><span class="p">.</span><span class="n">ContentType</span>
    <span class="p">});</span>
    
    <span class="k">return</span> <span class="n">Results</span><span class="p">.</span><span class="nf">Ok</span><span class="p">(</span><span class="k">new</span>
    <span class="p">{</span>
        <span class="n">Url</span> <span class="p">=</span> <span class="k">new</span> <span class="nf">Uri</span><span class="p">(</span><span class="n">storageOptions</span><span class="p">.</span><span class="n">Value</span><span class="p">.</span><span class="n">PublicBaseUrl</span><span class="p">,</span> <span class="n">key</span><span class="p">)</span>
    <span class="p">});</span>
<span class="p">})</span>
<span class="c1">// Disable antiforgery for simplicity in this example</span>
<span class="p">.</span><span class="nf">DisableAntiforgery</span><span class="p">();</span>

<span class="c1">// Download endpoint</span>
<span class="n">app</span><span class="p">.</span><span class="nf">MapGet</span><span class="p">(</span><span class="s">"/download/{key}"</span><span class="p">,</span> <span class="k">async</span> <span class="p">(</span>
    <span class="kt">string</span> <span class="n">key</span><span class="p">,</span>
    <span class="n">IAmazonS3</span> <span class="n">s3Client</span><span class="p">,</span>
    <span class="n">IOptions</span><span class="p">&lt;</span><span class="n">StorageOptions</span><span class="p">&gt;</span> <span class="n">storageOptions</span><span class="p">)</span> <span class="p">=&gt;</span>
<span class="p">{</span>
    <span class="kt">var</span> <span class="n">bucketName</span> <span class="p">=</span> <span class="n">storageOptions</span><span class="p">.</span><span class="n">Value</span><span class="p">.</span><span class="n">BucketName</span><span class="p">;</span>

    <span class="k">try</span>
    <span class="p">{</span>
        <span class="kt">var</span> <span class="n">response</span> <span class="p">=</span> <span class="k">await</span> <span class="n">s3Client</span><span class="p">.</span><span class="nf">GetObjectAsync</span><span class="p">(</span><span class="n">bucketName</span><span class="p">,</span> <span class="n">key</span><span class="p">);</span>
        <span class="k">return</span> <span class="n">Results</span><span class="p">.</span><span class="nf">File</span><span class="p">(</span><span class="n">response</span><span class="p">.</span><span class="n">ResponseStream</span><span class="p">,</span> <span class="n">response</span><span class="p">.</span><span class="n">Headers</span><span class="p">[</span><span class="s">"Content-Type"</span><span class="p">],</span> <span class="n">key</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">catch</span> <span class="p">(</span><span class="n">AmazonS3Exception</span> <span class="n">ex</span><span class="p">)</span> <span class="k">when</span> <span class="p">(</span><span class="n">ex</span><span class="p">.</span><span class="n">StatusCode</span> <span class="p">==</span> <span class="n">System</span><span class="p">.</span><span class="n">Net</span><span class="p">.</span><span class="n">HttpStatusCode</span><span class="p">.</span><span class="n">NotFound</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">Results</span><span class="p">.</span><span class="nf">NotFound</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">});</span>

<span class="n">app</span><span class="p">.</span><span class="nf">Run</span><span class="p">();</span>

<span class="c1">// Storage configuration class</span>
<span class="c1">// Maps to Storage section in configuration:</span>
<span class="c1">// Storage:BucketName and Storage:PublicBaseUrl</span>
<span class="k">public</span> <span class="k">sealed</span> <span class="k">class</span> <span class="nc">StorageOptions</span>
<span class="p">{</span>
    <span class="k">public</span> <span class="kt">string</span> <span class="n">BucketName</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">init</span><span class="p">;</span> <span class="p">}</span> <span class="p">=</span> <span class="k">null</span><span class="p">!;</span>
    <span class="k">public</span> <span class="n">Uri</span> <span class="n">PublicBaseUrl</span> <span class="p">{</span> <span class="k">get</span><span class="p">;</span> <span class="k">init</span><span class="p">;</span> <span class="p">}</span> <span class="p">=</span> <span class="k">null</span><span class="p">!;</span>
<span class="p">}</span>
</code></pre></div></div> <p>The beauty here is that the LocalStack client automatically falls back to real AWS when LocalStack isn’t enabled. We don’t need conditional registration - just use <code class="language-plaintext highlighter-rouge">AddAwsService&lt;...&gt;()</code> everywhere and let the configuration decide where calls go. The LocalStack Client uses these injected settings (<code class="language-plaintext highlighter-rouge">LocalStack__</code> from above, especially <code class="language-plaintext highlighter-rouge">LocalStack__UseLocalStack</code>) to route requests to LocalStack when enabled, or to real AWS otherwise.</p> <p>So now we can run our Aspire application again and test the API service. We can use tools like Postman or curl to upload and download files. Since LocalStack is running, all Amazon S3 operations happen locally without touching real AWS. We can even use the AWS CLI to inspect the LocalStack S3 bucket. In my case it looks like this:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Upload a file using curl</span>
curl <span class="nt">-v</span> <span class="s2">"http://localhost:22456/upload"</span> <span class="nt">-F</span> <span class="s2">"file=@index.html"</span>
  <span class="c"># *   Trying 127.0.0.1:22456...</span>
  <span class="c"># * Connected to localhost (127.0.0.1) port 22456 (#0)</span>
  <span class="c"># &gt; POST /upload HTTP/1.1</span>
  <span class="c"># &gt; Host: localhost:22456</span>
  <span class="c"># &gt; User-Agent: curl/7.87.0</span>
  <span class="c"># &gt; Accept: */*</span>
  <span class="c"># &gt; Content-Length: 326</span>
  <span class="c"># &gt; Content-Type: multipart/form-data; boundary=------------------------90474ff5b4693aae</span>
  <span class="c"># &gt; </span>
  <span class="c"># * We are completely uploaded and fine</span>
  <span class="c"># * Mark bundle as not supporting multiuse</span>
  <span class="c"># &lt; HTTP/1.1 200 OK</span>
  <span class="c"># &lt; Content-Type: application/json; charset=utf-8</span>
  <span class="c"># &lt; Date: Wed, 02 Nov 2025 16:44:05 GMT</span>
  <span class="c"># &lt; Server: Kestrel</span>
  <span class="c"># &lt; Transfer-Encoding: chunked</span>
  <span class="c"># &lt; </span>
  <span class="c"># * Connection #0 to host localhost left intact</span>
  <span class="c"># {"url":"http://test-data-bucket.s3-website.localhost:33895/index.html"}%  </span>

<span class="c"># List objects in the S3 bucket using AWS CLI</span>
aws <span class="nt">--endpoint-url</span><span class="o">=</span><span class="s2">"http://localhost:33895"</span> s3 <span class="nb">ls </span>s3://test-data-bucket
  <span class="c"># 2025-11-02 17:44:05        139 index.html</span>

<span class="c"># Download the file using curl</span>
curl <span class="nt">-v</span> <span class="s2">"http://localhost:22456/download/index.html"</span> <span class="nt">-o</span> downloaded_index.html
</code></pre></div></div> <p>Useful links:</p> <ul> <li><a href="https://github.com/localstack/localstack">LocalStack</a></li> <li><a href="https://github.com/localstack-dotnet/dotnet-aspire-for-localstack">.NET Aspire Integrations for LocalStack</a></li> <li><a href="https://github.com/localstack-dotnet/localstack-dotnet-client">LocalStack .NET Client</a></li> <li><a href="https://aws.amazon.com/blogs/developer/integrating-aws-with-net-aspire/">Integrating AWS with .NET Aspire</a></li> <li><a href="https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-csharp.html">Working with the AWS CDK in C#</a></li> <li><a href="https://github.com/localstack-dotnet/dotnet-aspire-for-localstack/tree/master/playground/provisioning">LocalStack Provisioning Examples</a></li> </ul>]]></content><author><name>Nazarii Piontko</name></author><category term="dotnet"/><category term="aspire"/><category term="aws"/><category term="localstack"/><summary type="html"><![CDATA[Stop paying for AWS services during development or fighting over shared dev environments. Here's how to use LocalStack with .NET Aspire to develop against AWS services locally, with automatic configuration injection that makes the same code work in development and production.]]></summary></entry><entry><title type="html">How to Deliver Realistic Software Project Estimations in High Uncertainty</title><link href="https://www.npiontko.pro/2025/08/03/pert" rel="alternate" type="text/html" title="How to Deliver Realistic Software Project Estimations in High Uncertainty"/><published>2025-08-03T00:00:00+00:00</published><updated>2025-08-03T00:00:00+00:00</updated><id>https://www.npiontko.pro/2025/08/03/pert</id><content type="html" xml:base="https://www.npiontko.pro/2025/08/03/pert"><![CDATA[<p>Ever been asked to estimate a software project when you barely understand the requirements? You know the scenario: a manager appears with “Hey, we’ve got this new project…” and suddenly you need to predict development timelines, costs, and resource needs before the next board meeting.</p> <p>In Agile teams, we’re comfortable with story points for sprint planning. We know the scope, our velocity, our capacity. Everything works more or less smoothly within two-week cycles. But management wants dates and dollars. The situation gets even more challenging when we’re standing there with nothing but a vague idea of what needs to be built, no team yet, no velocity to reference, no historical data from similar projects, just a vision, maybe few documents and wireframes, and an executive who needs an answer.</p> <p>I’ve been in this situation countless times, especially in my recent presales work where I’m doing these estimations few times per week. Here’s how I handle it without giving estimates that are either wildly inaccurate or so padded they’re useless.</p> <p>Let me provide a concrete imaginary example. A mid-size manufacturing company needed an inventory management system. They wanted to track raw materials, manage supplier relationships, monitor stock levels, integrate with existing systems, and generate reports for procurement teams. Sounds like a standard inventory system. But “standard” doesn’t mean simple, and it definitely doesn’t mean the scope won’t explode.</p> <h2 id="breaking-it-down">Breaking It Down</h2> <p>First thing, you need to break this down into “features” you can actually wrap your head around. But here’s the thing about feature breakdown - there’s a sweet spot: go too big, like “build supplier management” as a single feature, and you’re just guessing. That could be anywhere from a week to three months depending on what “supplier management” means; go too small, breaking everything down into 1-2 man-day features, and you’ve essentially reinvented waterfall - you’ll spend more time planning than building.</p> <p>What works for me is thinking in terms of something between 3-5 man-days to 15-20 man-days (1-2 sprints) maximum. If I can’t estimate something within 15-20 man-days, it needs to be broken down further. Of course, I might often get features that take only 1-2 man-days, but that’s not a problem as long as I don’t need to estimate hundreds of such features - I don’t want to turn estimations into waterfall, I don’t want to have 500 very small features with estimations taking a week while still having too much uncertainty. Similarly, in opposite direction, sometimes I might estimate feature as 30+ man-days but only if I have historical data from similar activities.</p> <p>Who does this breakdown? It depends on the company. Bigger ones have business analysts (aka requirement engineers) for this, but in smaller companies, it’s usually engineers doing it themselves. And that’s fine, but here’s a mistake I see constantly, engineers decomposing work into technical tasks. “Design database schema”, “Implement REST API”, “Create domain model”. However, that’s not how modern software gets built. Don’t get me wrong, these are valid tasks for individual feature breakdown during development, but it’s hard to imagine a scenario where you design the complete database for all features upfront. In the real world, you build features iteratively. You deliver business value and the database evolves as you go. So instead, think about what users can actually do: “Users can create an account and log in”, “Staff can receive and process incoming inventory shipments”, “Manager can generate reports for procurement teams”, etc.</p> <p>Of course, there are exceptions. You do need to set up CI/CD pipelines. You do need to configure your infrastructure. Set up logging, monitoring, all that stuff. These are legitimate technical tasks that happen usually at the project start. Estimate them separately or as a bundle based on experience - most engineers know roughly how long their standard setup takes in their environment.</p> <p>When I’m doing the breakdown myself (it happens not that often in fact due to BAs who do it in my current company), I like to think in terms of modules or functional areas first. For our inventory system: “User Management”, “Product Catalog”, “Stock Management”, “Purchase Orders”, “Supplier Management”, “Integration with ERP”, “Reporting Dashboard”, “Admin Panel”, etc. And then I break each of these down further. For example, “Stock Management” might include: “Staff can receive and process incoming inventory shipments”, “Users can view current stock levels and locations”, “Warehouse staff can move items between locations”, “Managers can adjust stock quantities for damaged or lost items”, etc. Each of these is something a user does, not something software engineer does.</p> <h2 id="beyond-single-point-estimates">Beyond Single-Point Estimates</h2> <p>So, you’ve got your tasks broken down. Now comes the part where most estimates go wrong - putting numbers on them. The naive approach is to slap a effort number on each task. “Staff can receive and process incoming inventory shipments? 10 man-days.” But that’s not an estimation, that’s a guess dressed up in false precision.</p> <p>Here’s where <a href="https://en.wikipedia.org/wiki/Program_evaluation_and_review_technique"><strong>PERT</strong></a> comes in. Instead of one effort number, you give three: <strong>optimistic</strong>, <strong>realistic</strong>, and <strong>pessimistic</strong>. Each number represents a different scenario, and thinking through all three makes your estimations way more accurate.</p> <p>Back to the feature “Staff can receive and process incoming inventory shipments”:</p> <ul> <li> <p><strong>Optimistic scenario</strong>. Basic shipment receipt with manual entry of quantities, simple item lookup by SKU, update stock levels in database, print basic receipt. This is the “nothing goes wrong, no surprises, no scope creep” version. It almost never happens, but it could. <strong>4 man-days of development</strong> <small>(just an example, I don’t know the actual numbers for this feature 😜, it sounds like 1-2 days on backend and 2 days on frontend)</small>.</p> </li> <li> <p><strong>Realistic scenario</strong>. Now we add barcode scanning for items, purchase order validation, discrepancy tracking when received quantities don’t match expected, quality inspection workflow, automatic supplier notifications, and proper audit trails. This is what you’ll probably actually build because these aren’t nice-to-haves, they’re essential for any real warehouse operation. <strong>9 man-days of development</strong> <small>(just another example estimate)</small>.</p> </li> <li> <p><strong>Pessimistic scenario</strong>. Everything above plus photo documentation for damaged items and approval workflows for high-value discrepancies. This is the “everything that could reasonably be added to this feature gets added” version. <strong>12 man-days of development</strong> <small>(just another example estimate)</small>.</p> </li> </ul> <p>In the PERT approach, the <strong>expected effort</strong> is calculated as:</p> \[Expected = \frac{Optimistic + 4 \times Realistic + Pessimistic}{6}\] <p>So for “Staff can receive and process incoming inventory shipments”:</p> \[Expected = \frac{4 + 4 \times 9 + 12}{6} = 8.67 \approx 9 \text{ man-days of development}\] <p>Notice how it’s weighted toward the realistic estimate? That’s intentional. The optimistic and pessimistic scenarios are outliers, the realistic is what usually happens. There are variations of PERT that use different weights, but this is the most common one.</p> <p>Let’s have another example. Product search.</p> <ul> <li> <p><strong>Optimistic scenario</strong>. Basic keyword search, filter by category, show results in a grid, add pagination. Simple, just make it work. <strong>3 man-days of development</strong>.</p> </li> <li> <p><strong>Realistic scenario</strong>. Full-text search with relevance, multiple filters that actually make sense, sorting options. <strong>5 man-days of development</strong>.</p> </li> <li> <p><strong>Pessimistic scenario</strong>. Filter dependencies, performance optimization for 100,000+ SKUs. <strong>8 man-days of development</strong>.</p> </li> </ul> \[Expected = \frac{3 + 4 \times 5 + 8}{6} = 5.17 \approx 6 \text{ man-days of development}\] <p>What goes into pessimistic estimates? Include technical complexity, performance requirements, integration challenges. But don’t include external dependencies like “waiting for API access” or “legal review takes forever”. Those are project risks, not estimation factors. Handle them separately or you’ll have unusable estimates.</p> <p>Also, it’s absolutely essential to define and record what you’re estimating. Are you estimating pure development time, with testing and documentation handled as a percentage on top? Are you breaking down backend, frontend, and testing separately? Are you including code reviews, deployment, and integration work? There’s no right or wrong approach, but you must be consistent and explicit about your scope.</p> <p>In the examples above, I’ve put estimations for pure development - no testing, no code review, no documentation. If your team typically spends 30% additional time on testing and code review (probably this is the most standard case), factor that in separately or adjust your estimates accordingly. The key is documenting these assumptions, so everyone understands what the numbers represent.</p> <h2 id="adding-confidence-levels">Adding Confidence Levels</h2> <p>Now that we have expected estimates, we need to quantify our uncertainty. PERT gives us the most likely effort, but how confident are we in that number? To answer this, we need to calculate the <strong>standard deviation</strong> first. The standard deviation shows how much the actual effort is likely to deviate from the expected effort. The standard deviation, σ (sigma), is calculated as:</p> \[\sigma = \frac{Pessimistic - Optimistic}{6}\] <p>For our examples:</p> <ul> <li>Receiving shipments: \(\sigma = (12 - 4) ÷ 6 = 1.33 \text{ man-days}\)</li> <li>Search feature: \(\sigma = (8 - 3) ÷ 6 = 0.83 \text{ man-days}\)</li> </ul> <p>The expected estimate from PERT formula gives <strong>50% confidence</strong>. To increase the confidence, you need to add multiples of standard deviation to the expected estimate. The multiples are called <strong>z-scores</strong> (one-sided for estimates). Here are some common z-scores and their corresponding confidence levels:</p> <table> <thead> <tr> <th style="text-align: center">Z-score</th> <th style="text-align: center">Confidence Level</th> <th style="text-align: left">Use Case</th> </tr> </thead> <tbody> <tr> <td style="text-align: center">+0</td> <td style="text-align: center"><strong>50%</strong></td> <td style="text-align: left">Typical PERT estimate</td> </tr> <tr> <td style="text-align: center">+0.25</td> <td style="text-align: center"><strong>60%</strong></td> <td style="text-align: left">Low certainty buffer</td> </tr> <tr> <td style="text-align: center">+0.52</td> <td style="text-align: center"><strong>70%</strong></td> <td style="text-align: left"> </td> </tr> <tr> <td style="text-align: center">+0.84</td> <td style="text-align: center"><strong>80%</strong></td> <td style="text-align: left">Recommended for PERT estimate</td> </tr> <tr> <td style="text-align: center">+1.28</td> <td style="text-align: center"><strong>90%</strong></td> <td style="text-align: left">High certainty</td> </tr> <tr> <td style="text-align: center">+1.64</td> <td style="text-align: center"><strong>95%</strong></td> <td style="text-align: left">Very High certainty</td> </tr> <tr> <td style="text-align: center">+2.33</td> <td style="text-align: center"><strong>99%</strong></td> <td style="text-align: left">Worst-case planning</td> </tr> </tbody> </table> <p><small>If you would like to calculate confidence level for specific number of standard deviations, you can Excel function <a href="https://support.microsoft.com/en-us/office/normsinv-function-8d1bce66-8e4d-4f3b-967c-30eed61f019d">NORMSINV</a>.</small></p> <p>So here’s how you do it for multiple features:</p> <ol> <li> <p><strong>Calculate the total expected effort of all features</strong>: \(Expected_{total} = Expected_1 + Expected_2 + Expected_3 + ...\)</p> </li> <li> <p><strong>Calculate the total standard deviation of all features</strong>: \(\sigma_{total} = \sqrt{σ_1^2 + σ_2^2 + σ_3^2 + ...}\) Notice that we square each standard deviation, add them up, and then take the square root of the sum.</p> </li> <li> <p><strong>Add the multiplication of z-score and standard deviation to the total expected effort to get the final estimation</strong>: \(Estimation = Expected_{total} + (Z_{score} \times σ_{total})\)</p> </li> </ol> <p>Example, lets assume Stock Management module has the following features:</p> <ul> <li>Staff can receive and process incoming inventory shipments: 8.67 man-days, σ=1.33</li> <li>Users can view current stock levels and locations: 5 man-days, σ=1.2</li> <li>Warehouse staff can move items between locations: 4 man-days, σ=1.1</li> </ul> <p>Step 1: Total expected effort = 8.67 + 5 + 4 = 17.67 man-days</p> <p>Step 2: Total standard deviation = √(1.33² + 1.2² + 1.1²) = √4.42 = 2.1 man-days</p> <p>Step 3: Estimation for different confidence levels:</p> <ul> <li><strong>50% confidence</strong>: 18 man-days (no additional buffer)</li> <li><strong>80% confidence</strong>: 17.67 + (0.84 × 2.1) = 19.43 ≈ 20 man-days</li> <li><strong>90% confidence</strong>: 17.67 + (1.28 × 2.1) = 20.36 ≈ 21 man-days</li> <li><strong>95% confidence</strong>: 17.67 + (1.65 × 2.1) = 21.14 ≈ 22 man-days</li> </ul> <h2 id="communicating-uncertainty">Communicating Uncertainty</h2> <p>When you present estimates with confidence levels, you’re not just giving numbers - you’re enabling informed decision-making. So instead of saying “Stock Management will take 18 days” and hoping for the best, you can say: “I expect 18 days, but that’s only 50% confidence. If you want 80% confidence, plan for 20 days. For 95% confidence, budget 22 days”. Much better than pretending uncertainty doesn’t exist.</p> <p>This approach transforms project conversations. Instead of arguing whether estimates are “right” or “wrong”, you’re discussing risk tolerance and business priorities. A startup might accept 60% confidence to move fast and cut corners, while a regulated industry might demand 95% confidence to ensure everything works as expected. The math stays the same, but the business decision becomes explicit. When stakeholders understand that higher confidence means more time and budget, they can make informed trade-offs rather than just demanding impossible certainty.</p> <h2 id="tips">Tips</h2> <ul> <li> <p><strong>Do not estimate alone</strong>. If possible, involve the actual implementers in the estimation process, if not, add at least 1-2 additional persons who can challenge your assumptions. Their experience and knowledge will help you make more accurate estimates.</p> </li> <li> <p><strong>Document assumptions</strong>. Document what’s included, what’s not, what you’re assuming about the tech stack, integrations, scenarios for optimistic, realistic and pessimistic estimations, everything.</p> </li> <li> <p><strong>There will always be uncertainty</strong>. Apply common sense, make assumptions, document them, and move on with estimations. During project development, review your assumptions and keep the scope close to them.</p> </li> <li> <p><strong>Don’t be too pessimistic on all estimations</strong>. Pessimistic scenarios are outliers, not what usually happens. Some features might exceed your pessimistic estimate, that’s fine, but on average, the whole project will be closer to your estimation.</p> </li> <li> <p><strong>Account for overhead</strong>. Do not forget to account for overhead, such as code reviews, testing, Scrum ceremonies, and those “quick sync” meetings that sometimes eat half of the day. Decide whether you want to include it in your estimates explicitly or add it as a percentage on top. Typically you can expect about 30-40% additional time for testing and code review, and 10-20% for meetings.</p> </li> <li> <p><strong>Use historical data</strong>. If you have data from similar past projects, use it to sanity-check your estimates. If you don’t have historical data, start tracking it.</p> </li> </ul> <h2 id="last-but-not-least">Last but not least</h2> <p>Nobody expects perfection, but everyone appreciates honesty about uncertainty. You’re not a fortune teller. The goal is giving decision-makers useful information while being honest about uncertainty. Next time someone asks for an estimate, try this: break it into user-facing features, use three-point estimation, calculate standard deviation, present confidence levels. Your estimates will actually help people make decisions instead of just being numbers you pulled out of thin air.</p> <h2 id="further-reading">Further Reading</h2> <p>What I’ve shown here uses the basic PERT approach. If you want to get fancy with more sophisticated techniques, learn alternative approaches, check out <a href="https://www.amazon.com/Software-Estimation-Demystifying-Developer-Practices/dp/0735605351/">Steve McConnell’s “Software Estimation: Demystifying the Black Art”</a>.</p> <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-AMS-MML_HTMLorMML" type="text/javascript" async=""></script>]]></content><author><name>Nazarii Piontko</name></author><category term="software-development"/><category term="project-management"/><category term="estimation"/><category term="pert"/><category term="agile"/><summary type="html"><![CDATA[When stakeholders need software project estimates but you only have vague requirements, single-point estimates are useless. Using PERT estimation and statistical confidence levels, you can quantify uncertainty and give decision-makers the information they actually need.]]></summary></entry><entry><title type="html">Transactional Outbox Pattern: Now with Optimistic Sending</title><link href="https://www.npiontko.pro/2025/05/26/outbox-pattern-optimistic" rel="alternate" type="text/html" title="Transactional Outbox Pattern: Now with Optimistic Sending"/><published>2025-05-26T00:00:00+00:00</published><updated>2025-05-26T00:00:00+00:00</updated><id>https://www.npiontko.pro/2025/05/26/outbox-pattern-optimistic</id><content type="html" xml:base="https://www.npiontko.pro/2025/05/26/outbox-pattern-optimistic"><![CDATA[<p>In the <a href="/2025/05/19/outbox-pattern">previous article</a>, I’ve described the <strong>standard transactional outbox pattern</strong> - a practical solution for ensuring that both database operations and event dispatches succeed together, or not at all. It’s a clever workaround for the fact that distributed systems don’t support distributed transactions out of the box. By using an <strong>Outbox Table</strong> and a background <strong>Relay Process</strong>, we can keep our services decoupled while maintaining consistency guarantees.</p> <p>But once it’s implemented and especially once it hits production, you start to notice its rough edges.</p> <p>Polling introduces lag into the system. Even if the polling interval is short, say, 50ms or 100ms, there’s still a gap between when a business operation completes and when its corresponding event gets published. And the worst part, that lag isn’t even predictable. It depends on poll timing, system load, database response time, and more. Sometimes it’s 50ms, sometimes it’s 2 seconds, sometimes it’s even more. In addition to that, regular polling creates pressure on the database. It might look like “it’s just one small <code class="language-plaintext highlighter-rouge">SELECT</code> query every so often”. But few polling replicas, expensive locking functionality, and simple “<code class="language-plaintext highlighter-rouge">SELECT</code> query” starts to utilize quite a significant portion of the database CPU, I/O, and locks. In a system I worked on using Amazon RDS, it was the reason for quite a nasty incident. And finally, last but not least, monitoring, the standard transaction outbox pattern requires solid monitoring. Many forget about it until it’s too late. Some libraries provide monitoring capabilities out of the box, but not all do.</p> <p>So, what are our options to improve the performance of the standard transactional outbox pattern implementation? I call it Optimistic Sending (maybe it is not the fully correct name, but still, I like it, reference to optimistic locking 😅).</p> <p>Let’s start with a quick thoughtful experiment. Say a single-instance AWS RDS database guarantees 99.5% uptime, and AWS SQS guarantees 99.9%. These aren’t edge-case numbers, it is possible to achieve even higher numbers for uptime in AWS or Azure/GCP. Nevertheless, what is the combined uptime of RDS and SQS in this case, slightly higher than 99.4% (0.999 * 0.995). It means that we can expect failure for about 0.6% of the time. It’s about <strong>1m 26s daily</strong> and <strong>10m weekly</strong>. Not that bad, right? In reality, it’s even better than that, we can expect failures for individual transactions/events per day.</p> <p>So, what does it give us? Let’s do the small shift in mindset, not architecture. We still write events to the outbox table and still have the relay process running - nothing critical gets removed. However, after committing a transaction, we <strong>immediately try to send the event to the broker</strong>:</p> <ul> <li>If it works? ✅ We clean up the outbox row (either delete or mark as sent).</li> <li>If it fails? ❌ We do nothing. The relay process will pick it up later, just like it always would.</li> </ul> <p>The outbox goes from being the <strong>default queue</strong> to a <strong>backup queue</strong>, relay process becomes a <strong>failure-handling fallback</strong>. So, we are not removing the relay process, it just gets <strong>less work</strong>. It still polls the database, but more rarely, only looking for messages that didn’t make it out for certain period of time, say, 30 seconds. Also we safely can leave one relay process instead of trying to improve it. In a perfect and most probable scenario the relay never actually sends anything.</p> <p>Here’s a high-level flow of the happy path vs fallback:</p> <p><img src="/uml/f0b0df725a2066ce7f3494a24db46862.svg" class="plantuml"/></p> <p>And here is a simple C# example:</p> <div class="language-csharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">/// &lt;summary&gt;</span>
<span class="c1">/// Creates a user and tries to send a `UserCreated` event optimistically.</span>
<span class="c1">/// &lt;/summary&gt;</span>
<span class="k">public</span> <span class="k">async</span> <span class="n">Task</span> <span class="nf">CreateUserAsync</span><span class="p">(</span><span class="n">UserDto</span> <span class="n">input</span><span class="p">)</span>
<span class="p">{</span>
    <span class="c1">// Create domain object</span>
    <span class="kt">var</span> <span class="n">user</span> <span class="p">=</span> <span class="k">new</span> <span class="n">User</span> <span class="p">{</span> <span class="n">Id</span> <span class="p">=</span> <span class="n">Guid</span><span class="p">.</span><span class="nf">NewGuid</span><span class="p">(),</span> <span class="n">Email</span> <span class="p">=</span> <span class="n">input</span><span class="p">.</span><span class="n">Email</span> <span class="p">};</span>
    <span class="n">_dbContext</span><span class="p">.</span><span class="n">Users</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">user</span><span class="p">);</span>


    <span class="c1">// Prepare outbox event alongside domain entity</span>
    <span class="kt">var</span> <span class="n">userCreatedEvent</span> <span class="p">=</span> <span class="k">new</span> <span class="n">UserCreated</span> <span class="p">{</span> <span class="n">user</span><span class="p">.</span><span class="n">Id</span><span class="p">,</span> <span class="n">user</span><span class="p">.</span><span class="n">Email</span> <span class="p">}</span>
    <span class="kt">var</span> <span class="n">outboxEvent</span> <span class="p">=</span> <span class="k">new</span> <span class="n">OutboxEvent</span>
    <span class="p">{</span>
        <span class="n">Id</span> <span class="p">=</span> <span class="n">Guid</span><span class="p">.</span><span class="nf">NewGuid</span><span class="p">(),</span>
        <span class="n">EventType</span> <span class="p">=</span> <span class="n">userCreatedEvent</span><span class="p">.</span><span class="nf">GetType</span><span class="p">().</span><span class="n">Name</span><span class="p">,</span>
        <span class="n">Payload</span> <span class="p">=</span> <span class="n">JsonSerializer</span><span class="p">.</span><span class="nf">Serialize</span><span class="p">(</span><span class="n">userCreatedEvent</span><span class="p">),</span>
        <span class="n">CreatedAt</span> <span class="p">=</span> <span class="n">DateTime</span><span class="p">.</span><span class="n">UtcNow</span>
    <span class="p">};</span>
    <span class="n">_dbContext</span><span class="p">.</span><span class="n">OutboxEvents</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="n">outboxEvent</span><span class="p">);</span>
    
    <span class="c1">// Atomic insert of domain state and outbox event to database</span>
    <span class="k">await</span> <span class="n">_dbContext</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">();</span>

    <span class="k">try</span>
    <span class="p">{</span>
        <span class="c1">// Attempt to publish right after commit</span>
        <span class="k">await</span> <span class="n">_eventPublisher</span><span class="p">.</span><span class="nf">PublishAsync</span><span class="p">(</span><span class="n">outboxEvent</span><span class="p">);</span>

        <span class="c1">// Mark sent event for deletion</span>
        <span class="n">_dbContext</span><span class="p">.</span><span class="n">OutboxEvents</span><span class="p">.</span><span class="nf">Remove</span><span class="p">(</span><span class="n">outboxEvent</span><span class="p">);</span>

        <span class="c1">// Send delete command to database</span>
        <span class="k">await</span> <span class="n">_dbContext</span><span class="p">.</span><span class="nf">SaveChangesAsync</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">catch</span> <span class="p">(</span><span class="n">Exception</span> <span class="n">ex</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">_logger</span><span class="p">.</span><span class="nf">LogWarning</span><span class="p">(</span><span class="n">ex</span><span class="p">,</span> <span class="s">"Optimistic send failed. Relay will handle it."</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>

</code></pre></div></div> <p>Now let’s examine potential issues with this approach:</p> <ul> <li> <p>Because of most events get sent immediately and a few get delayed, the <strong>order of delivery isn’t guaranteed</strong>. If order matters, the consumers need to handle it, or this implementation is not an option for that particular problem. To be honest, the standard implementation also does not guarantee in-order delivery by default.</p> </li> <li> <p>If the fallback logic has a “too short” acceptable delay window, the <strong>system might have many duplicates</strong>, but as with any other at-least-once delivery, the consumer should handle it.</p> </li> <li> <p>The observability is still needed. <strong>Observability is inevitable</strong>, but this time it might be slightly simpler.</p> </li> </ul> <p>The good about this approach: we don’t have to rebuild anything, just slightly upgrade the happy/default path. If you already using something like MediatR or any cross-cutting concern implementation for sending outbox events, it’s just a simple upgrade in just a few places of your codebase.</p>]]></content><author><name>Nazarii Piontko</name></author><category term="distributed-systems"/><category term="patterns"/><category term="outbox"/><category term="dotnet"/><summary type="html"><![CDATA[The standard transactional outbox pattern ensures consistency but adds latency and load. This article introduces optimistic sending - a simple upgrade that tries to publish events immediately and falls back only if needed. Faster, lighter, just as reliable.]]></summary></entry><entry><title type="html">Transactional Outbox Pattern: From Theory to Production</title><link href="https://www.npiontko.pro/2025/05/19/outbox-pattern" rel="alternate" type="text/html" title="Transactional Outbox Pattern: From Theory to Production"/><published>2025-05-19T00:00:00+00:00</published><updated>2025-05-19T00:00:00+00:00</updated><id>https://www.npiontko.pro/2025/05/19/outbox-pattern</id><content type="html" xml:base="https://www.npiontko.pro/2025/05/19/outbox-pattern"><![CDATA[<h1 id="introduction">Introduction</h1> <p>Imagine a microservices architecture. You’re developing a warehouse inventory service. When items arrive from suppliers, two operations must succeed in sequence: updating inventory counts in the database and sending restock events to the product catalog service. If either fails - due to a transient error, for instance - you risk stock discrepancies or outdated product availability, affecting customer experience and sales. Consider another scenario in your users service. You store new user data and notify other services. Fail to send the notification, and users might be unable to access key features. Send the notification before confirming storage, and other services may act on non-existent data.</p> <p>These are real risks in distributed systems. How can we ensure both database commits and event dispatches succeed together or not at all?</p> <p>Enter the transactional outbox pattern or usually just outbox pattern: an elegant solution for maintaining data consistency across service boundaries.</p> <h1 id="how-it-works">How It Works</h1> <p>The transactional outbox pattern introduces two additional components to ensure consistency between data changes and events dispatching in distributed systems: <strong>Outbox Table</strong> and <strong>Relay Process</strong>.</p> <p>The <strong>Outbox Table</strong> is an additional database table used to temporarily store events that need to be published. This table resides in the same database as service’s core data and is written to in the same transaction.</p> <p>For example, in the users service, when a new user is created, the application performs two operations in a single transaction:</p> <ul> <li>Inserts the new user record into the <code class="language-plaintext highlighter-rouge">users</code> table.</li> <li>Inserts a corresponding <code class="language-plaintext highlighter-rouge">UserCreated</code> event into the <code class="language-plaintext highlighter-rouge">outbox</code> table.</li> </ul> <p>By bundling both writes into one transaction, we ensure that either both operations succeed or both are rolled back (<a href="https://en.wikipedia.org/wiki/ACID">ACID</a> properties of database transactions).</p> <p>There’s no strict standard for the outbox table schema, however, a very basic structure might look like this:</p> <table> <thead> <tr> <th>Column Name</th> <th>Type</th> <th>Description</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">id</code></td> <td><code class="language-plaintext highlighter-rouge">INT</code> / <code class="language-plaintext highlighter-rouge">UUID</code></td> <td>Unique identifier for the outbox event</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">event_type</code></td> <td><code class="language-plaintext highlighter-rouge">STRING</code></td> <td>Type of the event (e.g., <code class="language-plaintext highlighter-rouge">UserCreated</code>)</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">payload</code></td> <td><code class="language-plaintext highlighter-rouge">JSON</code> / <code class="language-plaintext highlighter-rouge">TEXT</code></td> <td>Serialized event body</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">created_at</code></td> <td><code class="language-plaintext highlighter-rouge">TIMESTAMP</code></td> <td>Time when the event was stored</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">sent_at</code></td> <td><code class="language-plaintext highlighter-rouge">TIMESTAMP</code> <code class="language-plaintext highlighter-rouge">NULLABLE</code></td> <td>Time when the event was dispatched (optional)</td> </tr> </tbody> </table> <p>This table schema can definitely be expanded with additional fields as needed, such as:</p> <ul> <li><code class="language-plaintext highlighter-rouge">entity_id</code> and <code class="language-plaintext highlighter-rouge">entity_name</code> to track the domain object</li> <li><code class="language-plaintext highlighter-rouge">retries</code>, <code class="language-plaintext highlighter-rouge">status</code>, or <code class="language-plaintext highlighter-rouge">error_message</code> for debugging or advanced error handling</li> <li><code class="language-plaintext highlighter-rouge">correlation_id</code> for tracing</li> </ul> <p>The <strong>Relay Process</strong> is a background process, also often called as <strong>outbox processor</strong> or <strong>events dispatcher</strong>. It periodically polls the outbox table for unsent events and publishes them to the messaging system (such as <code class="language-plaintext highlighter-rouge">RabbitMQ</code>, <code class="language-plaintext highlighter-rouge">AWS EventBridge</code>, <code class="language-plaintext highlighter-rouge">Azure Service Bus</code>, <code class="language-plaintext highlighter-rouge">Kafka</code>, etc.). Once an event is successfully sent, the relay process either marks the record as dispatched or deletes the record.</p> <p>To better understand the architecture and events flow, here are two diagrams: <em>Components Diagram</em> and <em>Sequence Diagram</em>.</p> <p><img src="/uml/943cbe8725a6a230bd425e155cbdb9a2.svg" class="plantuml"/></p> <p>Figure 1. Components Diagram - illustrates the core parts of the pattern: the service, the outbox table, the relay process, and the messaging system.</p> <p><img src="/uml/d148595a4a1c1fec7453bdd6abde6f9a.svg" class="plantuml"/></p> <p>Figure 2. Sequence Diagram - shows the step-by-step lifecycle of a change, from transaction commit to event dispatch.</p> <p>There are many articles that explain the transactional outbox pattern, and multiple libraries can help to implement it without much effort. For example, in .NET, <a href="https://masstransit.io/"><code class="language-plaintext highlighter-rouge">MassTransit</code></a> supports the <a href="https://masstransit.io/documentation/patterns/transactional-outbox">transactional outbox pattern</a> and can handle events persistence automatically. But in real projects, it’s not always that easy. Sometimes we can’t use a library due to specific requirements or constraints. Other times, the library has limitations, or its features aren’t clearly documented. That’s when it becomes important to understand what to consider when running the transactional outbox pattern in production - how to make it stable, observable, scalable, and what trade-offs and failure modes to be aware of.</p> <h1 id="production-ready">Production Ready</h1> <h2 id="safely-selecting-unsent-events-in-the-relay-process">Safely Selecting Unsent Events in the Relay Process</h2> <p>At first glance, selecting unsent events might seem simple - just run a <code class="language-plaintext highlighter-rouge">SELECT</code> query to fetch a small batch (e.g. 10 rows) from the outbox table. But once we introduce <strong>multiple relay instances</strong> for scalability or fault tolerance, things get more complicated.</p> <p>We need to ensure that:</p> <ul> <li>Each event is processed <strong>only once</strong>.</li> <li>Relay processes <strong>don’t block or interfere</strong> with each other unnecessarily.</li> </ul> <p>To achieve this, we must add <strong>row-level locking</strong> to selection queries. This allows multiple relays to work safely in parallel without picking the same rows.</p> <p><strong><code class="language-plaintext highlighter-rouge">PostgreSQL</code></strong> offers a clean solution using <code class="language-plaintext highlighter-rouge">FOR UPDATE SKIP LOCKED</code>:</p> <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">outbox</span>
<span class="k">WHERE</span> <span class="n">sent_at</span> <span class="k">IS</span> <span class="k">NULL</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span>
<span class="k">LIMIT</span> <span class="mi">10</span>
<span class="k">FOR</span> <span class="k">UPDATE</span> <span class="n">SKIP</span> <span class="n">LOCKED</span><span class="p">;</span>
</code></pre></div></div> <ul> <li><code class="language-plaintext highlighter-rouge">FOR UPDATE</code>: locks the selected rows during the transaction.</li> <li><code class="language-plaintext highlighter-rouge">SKIP LOCKED</code>: skips any rows already locked by other transactions.</li> </ul> <p><strong><code class="language-plaintext highlighter-rouge">MySQL 8.0</code></strong> and newer support the same <code class="language-plaintext highlighter-rouge">FOR UPDATE SKIP LOCKED</code> syntax, so the same approach applies directly.</p> <p>In <strong><code class="language-plaintext highlighter-rouge">SQL Server</code></strong>, we can achieve similar behavior using locking hints:</p> <div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">TOP</span> <span class="mi">10</span> <span class="o">*</span>
<span class="k">FROM</span> <span class="n">outbox</span> <span class="k">WITH</span> <span class="p">(</span><span class="n">ROWLOCK</span><span class="p">,</span> <span class="n">UPDLOCK</span><span class="p">,</span> <span class="n">READPAST</span><span class="p">)</span>
<span class="k">WHERE</span> <span class="n">sent_at</span> <span class="k">IS</span> <span class="k">NULL</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span><span class="p">;</span>
</code></pre></div></div> <ul> <li><code class="language-plaintext highlighter-rouge">ROWLOCK</code>: enforces row-level locking (instead of page or table).</li> <li><code class="language-plaintext highlighter-rouge">READPAST</code>: skips rows that are already locked by other transactions.</li> <li><code class="language-plaintext highlighter-rouge">UPDLOCK</code>: acquires update locks instead of shared locks.</li> </ul> <p>Choosing the right row-locking strategy helps keep relay process safe and reliable under load.</p> <h2 id="handling-mid-batch-crashes">Handling Mid-Batch Crashes</h2> <p>Let’s say the relay process grabs a batch of events, starts sending them and crashes halfway through. What then?</p> <p>The good news is: if we’re using <code class="language-plaintext highlighter-rouge">FOR UPDATE SKIP LOCKED</code> (or equivalent), those unprocessed rows will become visible again once the transaction rolls back or the database connection closes. As long as we only mark events as sent after the broker confirms delivery, we’re safe - nothing gets lost.</p> <p>The downside? Some events might get retried. That’s fine if consumers handle duplicates (see idempotency), but it’s something to be aware of.</p> <h2 id="separating-the-relay-process-from-the-api-service">Separating the Relay Process from the API Service</h2> <p>In many implementations, especially those guided by default library configurations like <code class="language-plaintext highlighter-rouge">MassTransit</code>, it’s common to co-host the relay process. The background worker responsible for publishing events from the outbox within the main API service. This setup is convenient and works well for small-scale systems with low traffic or a limited number of replicas (typically fewer than five).</p> <p>However, this strategy doesn’t scale well.</p> <p>As service scales horizontally - say, to 10, 15, or even 30 replicas - each instance runs its own copy of the relay logic. This results in redundant and overlapping polling, where all instances query the outbox table at regular intervals (even a modest 25-50ms). What initially seemed like a harmless background task becomes a storm of database queries.</p> <p>This creates two critical issues:</p> <ul> <li> <p><strong>Database pressure and lock contention</strong>. Every relay process instance attempts to acquire locks on the outbox table to safely claim and process events. These locks are not cheap. In one of my services with <code class="language-plaintext highlighter-rouge">Amazon Aurora</code>, I’ve started noticing that the database spends considerable CPU time managing locks, not executing queries.</p> </li> <li> <p><strong>Mismatched scaling profiles</strong>. The core of the problem is architectural. API (REST API) traffic and relay process scale differently. API scales with user demand, which might require many instances to handle load. The outbox relay process, on the other hand, scales with write throughput - that is, how many new events are added to the outbox table. In many real-world systems, a single relay process instance is enough to handle even high volumes of events. Scaling it beyond that typically yields no benefit and only introduces locking and concurrency overhead.</p> </li> </ul> <p>The better approach is to <strong>separate the relay process from the API instance</strong> and run them as independent deployment units. They can still live in the same codebase but should be deployed with different configurations. For instance, one deployment might have the relay process enabled while another disables it and only handles HTTP traffic. This way, each component scales on its own terms. <code class="language-plaintext highlighter-rouge">MassTransit</code> supports this model. It is possible to control whether the relay process runs at startup using configuration (search for <code class="language-plaintext highlighter-rouge">DisableDeliveryService</code>).</p> <h2 id="handling-at-least-once-delivery">Handling At-Least-Once Delivery</h2> <p>It’s easy to overlook, but the transactional outbox pattern guarantees <strong>at-least-once delivery</strong> - not exactly-once. This means that due to transient failures (e.g., network issues, broker timeouts, retries), consumers might receive duplicate events. If downstream processing isn’t prepared for this, it risks inconsistent state or triggering actions multiple times (e.g., sending duplicate emails or creating double charges).</p> <p>To deal with this, there are two main options:</p> <ul> <li> <p><strong>Make logic idempotent</strong>. Design consumers so they can safely handle the same event more than once. For example, inserting a record only if it doesn’t exist.</p> </li> <li> <p><strong>Deduplicate events explicitly</strong>. Maintain a <strong>separate table</strong> (aka <code class="language-plaintext highlighter-rouge">inbox</code> table) to store processed event IDs for a defined retention period (e.g. one hour, six hours, or even a few days). Before handling a event, the consumer checks this store. If the ID is found, the event is skipped; otherwise, it’s processed and the ID is saved.</p> </li> </ul> <p>The event ID in <code class="language-plaintext highlighter-rouge">inbox</code> table must be globally unique - not just the domain entity’s ID. A single entity (e.g., a user or product) can generate multiple distinct events over time, and all of them must be independently tracked.</p> <h2 id="handling-out-of-order-delivery">Handling Out-of-Order Delivery</h2> <p>Besides duplicates, <strong>out-of-order events delivery</strong> is another common edge case in real-world deployments. This can happen due to:</p> <ul> <li>Multiple relay processes. When several instances read and dispatch events in parallel, event order is not guaranteed.</li> <li>Multiple consumer instances. Concurrent processing can lead to events being handled in a different order than they were created.</li> <li>Due to async retries or varying event latency some events could be delayed, while others no.</li> </ul> <p>What can be done to handle this:</p> <ul> <li>Including a timestamp in the event payload and resolve conflicts based on event time.</li> <li>Including a entity version number (or sequence ID) in the event payload and resolve conflicts based on version.</li> <li>Using message brokers that support ordering guarantees (e.g., Kafka partitions or Azure Service Bus sessions) if messages grouping by entity key is possible. Still, this alone does not eliminate issues on the producer side - out-of-order inserts into the outbox table or race conditions during transaction commits can still lead to event disorder.</li> </ul> <p>Designing for eventual consistency often means tolerating some degree of event disorder, but planning for it upfront ensures a more resilient system.</p> <h2 id="dont-forget-database-maintenance">Don’t Forget Database Maintenance</h2> <p><strong>Outbox (and inbox) tables tend to grow</strong> silently in the background. With every new event or processed event, a new row gets added. It’s easy to overlook this at first. In systems with moderate traffic and decent indexing, performance might hold up fine even with hundreds of thousands of records.</p> <p>But over time, things change. Queries that once ran instantly start to slow down. The database has to sift through more data, manage larger indexes, and handle more storage. Fetching new events or checking for duplicates becomes more expensive. Left unchecked, this can lead to subtle but persistent performance issues.</p> <p>That’s why it’s important to build in regular cleanup of both outbox and inbox tables. For the outbox, once a event has been successfully dispatched, it can often be removed immediately, unless there’s a need to keep it around for debugging or auditing. For the inbox, the retention window depends on how long duplicate events are expected to arrive. In most systems, keeping processed event IDs for a few hours to a couple of days is enough to cover retries and delayed deliveries.</p> <p>There’s no one-size-fits-all schedule, some systems might clean up every few hours, others daily. What matters is that <strong>cleanup is automatic and consistent</strong>. It doesn’t need to be complex. Just enough to keep things running smoothly and prevent silent degradation over time.</p> <h2 id="observability">Observability</h2> <p>The transactional outbox pattern implicitly introduces a <strong>secondary queue</strong> into system - one that exists within database, independent of messaging system. Most teams already monitor their messaging systems (e.g., RabbitMQ dashboards, Kafka exporters, Azure metrics), but the outbox itself often goes unobserved, especially when it’s implemented manually or using lightweight libraries.</p> <p>This oversight can be dangerous. The outbox is effectively a queue, and like any queue, it <strong>requires monitoring</strong> to ensure inflow and outflow stay balanced. If the relay process can’t keep up, queue grows, introducing latency in event propagation, and eventually, customer-facing lag or missed actions.</p> <p>At a minimum, we should monitor:</p> <ul> <li>The average age of unsent events - indicates how long events are waiting to be dispatched.</li> <li>Rate of incoming events - how fast new events are being added to the outbox.</li> <li>Rate of outgoing events – how fast the relay is processing and dispatching events.</li> <li>Errors within relay process.</li> </ul> <p>When these metrics drift apart - e.g., event age rising, or inflow consistently outpacing outflow - it’s a signal that something is wrong, the relay is failing, overloaded, or misconfigured.</p> <p>Modern runtimes like .NET make it easy to instrument this kind of telemetry using OpenTelemetry, Prometheus exporters, or Application Insights. Don’t guess - observe. Set up alerts, visualize trends, and establish baselines.</p> <p>This kind of visibility is especially critical because the outbox pattern operates under eventual consistency. For some domains, a few seconds of delay might be acceptable. For others, even short delays can introduce real risk.</p> <blockquote> <p>I once worked on a system with an internal currency mechanism. Users could spend internal currency on incentives. The balance deduction logic used an eventually consistent pipeline with an transactional outbox pattern. While we had safeguards, especially to roll back transactions if users didn’t have enough points, timing mattered. A user could trigger multiple transactions before the first slow deduction processed - and effectively “overspend”. Since some incentives involved real goods, this caused a serious incident.<br/><br/>Had we monitored the outbox queue itself, not just the external broker, we could have spotted the delay early, throttled users, or paused processing. Instead, the backlog built silently.<br/><br/>The root cause was the lack of separation between the API and the relay process.</p> </blockquote> <p>Rule of thumb: when a new queue is introduced (explicit or implicit), we <strong>MUST also introduce new monitoring</strong>. Without it, we’re flying blind.</p> <h1 id="wrapping-up">Wrapping Up</h1> <p>The transactional outbox pattern might seem simple at first glance. Just write an event to a table and send it later. But like most things in distributed systems, the devil is in the details. Making it work reliably in production means thinking about:</p> <ul> <li>How to safely select unsent events without collisions.</li> <li>Where the relay process should live and how it should scale.</li> <li>What to do with duplicates or events arriving out of order.</li> <li>How to keep the database healthy over time.</li> <li>And how to monitor the whole thing so you’re not flying blind when things slow down.</li> </ul> <p>If these parts are ignored, problems don’t show up immediately. They sneak in gradually - longer delays, subtle inconsistencies, and performance cliffs that are hard to debug when it’s already too late. But with the right approach, the transactional outbox pattern becomes a solid foundation for building resilient, event-driven systems.</p> <p>For most teams, especially when starting out, using a ready-to-use library is the best move. Libraries like <code class="language-plaintext highlighter-rouge">MassTransit</code> for .NET or <code class="language-plaintext highlighter-rouge">Axon Framework</code> for <code class="language-plaintext highlighter-rouge">Java</code> take care of the boilerplate and edge cases, allowing to focus on domain logic. If you can fit them into your stack and constraints, use them. Just be sure you understand how they behave in production, especially around delivery guarantees, scaling, and observability.</p> <p>This article focused on the classic outbox setup - write to database, regularly poll the database inside relay process. But this isn’t the only way.</p> <p>In the next article, I’ll look at other implementation options - ways to reduce lag and scale better. Coming soon.</p>]]></content><author><name>Nazarii Piontko</name></author><category term="distributed-systems"/><category term="patterns"/><category term="outbox"/><category term="dotnet"/><summary type="html"><![CDATA[When microservices need to update state and notify others at the same time, it’s easy to run into problems - lost events, race conditions, or partial failures. The transactional outbox pattern offers a practical way to ensure both data and events are handled reliably. This article breaks down how the pattern works, what makes it production-ready, and where things can go wrong.]]></summary></entry></feed>