<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-AU" xmlns:media="http://search.yahoo.com/mrss/">
  <id>https://david.gardiner.net.au/tags/.NET.xml</id>
  <title type="html">David Gardiner - .NET</title>
  <updated>2026-07-13T00:27:33.105Z</updated>
  <subtitle>Blog posts tagged with &apos;.NET&apos; - A blog of software development, .NET and other interesting things</subtitle>
  <rights>Copyright 2026 David Gardiner</rights>
  <icon>https://www.gravatar.com/avatar/37edf2567185071646d62ba28b868fab?s=64</icon>
  <logo>https://www.gravatar.com/avatar/37edf2567185071646d62ba28b868fab?s=256</logo>
  <generator uri="https://github.com/flcdrg/astrojs-atom" version="3">astrojs-atom</generator>
  <author>
    <name>David Gardiner</name>
  </author>
  <link href="https://david.gardiner.net.au/tags/.NET.xml" rel="self" type="application/atom+xml"/>
  <link href="https://david.gardiner.net.au/tags/.NET" rel="alternate" type="text/html" hreflang="en-AU"/>
  <category term=".NET"/>
  <category term="Software Development"/>
  <entry>
    <id>https://david.gardiner.net.au/2026/06/azure-function-errors</id>
    <updated>2026-06-11T23:00:00.000+09:30</updated>
    <title>.NET Azure Function error - A connection string was not found</title>
    <link href="https://david.gardiner.net.au/2026/06/azure-function-errors" rel="alternate" type="text/html" title=".NET Azure Function error - A connection string was not found"/>
    <category term=".NET"/>
    <category term="Azure Functions"/>
    <category term="macOS"/>
    <published>2026-06-11T23:00:00.000+09:30</published>
    <summary type="html">Surprisingly, a freshly created .NET Azure Function project isn&apos;t running for me without making a small, but critical configuration change.</summary>
    <content type="html">&lt;p&gt;I&apos;m trying out running &lt;a href=&quot;https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide?tabs=ihostapplicationbuilder%2Cconfig%2Cwindows&quot;&gt;.NET Azure Functions&lt;/a&gt; on my &lt;a href=&quot;/2026/06/macbookpro&quot;&gt;new laptop&lt;/a&gt;, and thought I&apos;d start from scratch by creating a brand new .NET Azure Function project.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/azure-function.B3FwAwqX_2tR59E.webp&quot; alt=&quot;Azure Function logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I installed the Azure Function Core Tools using Homebrew. (Note that &lt;a href=&quot;https://github.com/Azure/azure-functions-core-tools#homebrew&quot;&gt;official instructions&lt;/a&gt; missed the &apos;trust&apos; command):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brew tap azure/functions
brew trust azure/functions
brew install azure-functions-core-tools@4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I then created a new Function project using the CLI, following these &lt;a href=&quot;https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=macos%2Cisolated-process%2Cnode-v4%2Cpython-v2%2Chttp-trigger%2Ccontainer-apps&amp;amp;pivots=programming-language-csharp&quot;&gt;instructions&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func init --worker-runtime dotnet-isolated --language csharp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then attempt to run it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotnet run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And was greeted by this error:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Failed to start language worker process for runtime: dotnet-isolated. workerId:80487f5d-6d73-4858-a55a-2e56d0853367
Language Worker Process exited. Pid=4680.
Microsoft.Azure.WebJobs.Script.Grpc: dotnet exited with code 134 (0x86). Unhandled exception. System.InvalidOperationException: A connection string was not found. Please set your connection string.
Removing errored webhost language worker channel for runtime: dotnet-isolated workerId:80487f5d-6d73-4858-a55a-2e56d0853367
Microsoft.Azure.WebJobs.Script.Grpc: dotnet exited with code 134 (0x86). Unhandled exception. System.InvalidOperationException: A connection string was not found. Please set your connection string.
A host error has occurred during startup operation &apos;67dfffee-7a1e-4970-9014-209232677f0f&apos;.
Microsoft.Azure.WebJobs.Script.Grpc: dotnet exited with code 134 (0x86). Unhandled exception. System.InvalidOperationException: A connection string was not found. Please set your connection string.
Failed to stop host instance &apos;a4c866ff-2d8e-4423-b022-308f56a22348&apos;.
Microsoft.Azure.WebJobs.Host: The host has not yet started.
meter &apos;provider&apos;)
Host startup operation has been canceled
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s a weird error. My Function app is pretty much empty - there&apos;s the &lt;code&gt;Program.cs&lt;/code&gt; file but I haven&apos;t even added any actual Functions yet!&lt;/p&gt;
&lt;p&gt;A bit of searching and throwing the error at Copilot turned up the suggestion that it&apos;s the &lt;code&gt;UseAzureMonitorExporter()&lt;/code&gt; method call that is added by default in &lt;code&gt;Program.cs&lt;/code&gt; that is the culprit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;builder.Services.AddOpenTelemetry()
    .UseFunctionsWorkerDefaults()
    .UseAzureMonitorExporter();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is throwing an error because &lt;code&gt;APPLICATIONINSIGHTS_CONNECTION_STRING&lt;/code&gt; environment variable has not been set. It&apos;s a pity the error message was not more descriptive.&lt;/p&gt;
&lt;p&gt;The solution is to either change the code so that &lt;code&gt;UseAzureMonitorExporter()&lt;/code&gt; is only called if that environment variable has been set, or alternatively make sure that it is set.&lt;/p&gt;
&lt;p&gt;For local development you can add it to your &lt;code&gt;local.settings.json&lt;/code&gt; file. If you don&apos;t have a real App Insights resource to point to, you can set it to &lt;code&gt;InstrumentationKey=00000000-0000-0000-0000-000000000000&lt;/code&gt; which is recognised as a &quot;null&quot; connection string. With that value it no longer fails, but all traces and telemetry are just ignored.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;IsEncrypted&quot;: false,
  &quot;Values&quot;: {
    &quot;AzureWebJobsStorage&quot;: &quot;UseDevelopmentStorage=true&quot;,
    &quot;FUNCTIONS_WORKER_RUNTIME&quot;: &quot;dotnet-isolated&quot;,
    &quot;APPLICATIONINSIGHTS_CONNECTION_STRING&quot;: &quot;InstrumentationKey=00000000-0000-0000-0000-000000000000&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you deployed this Function App to Azure, you would ideally have a real Application Insights resource, and had set the &lt;code&gt;APPLICATIONINSIGHTS_CONNECTION_STRING&lt;/code&gt; to point to it.&lt;/p&gt;
&lt;p&gt;See &lt;a href=&quot;https://learn.microsoft.com/en-us/azure/azure-monitor/app/connection-strings&quot;&gt;Connection strings in Application Insights&lt;/a&gt; for more info about what options there are for connection strings.&lt;/p&gt;
&lt;p&gt;Not an ideal experience. Hopefully they&apos;ll update the generated code so that it includes that so things work right out of the box.&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/azure-function.B3FwAwqX.png" width="400" height="400"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/azure-function.B3FwAwqX.png" width="400" height="400"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/04/vs-debugging-fatal-error</id>
    <updated>2026-04-10T12:00:00.000+09:30</updated>
    <title>Fatal error 0x8013132d debugging .NET 10 applications in Visual Studio</title>
    <link href="https://david.gardiner.net.au/2026/04/vs-debugging-fatal-error" rel="alternate" type="text/html" title="Fatal error 0x8013132d debugging .NET 10 applications in Visual Studio"/>
    <category term=".NET"/>
    <category term="Aspire"/>
    <category term="Visual Studio"/>
    <published>2026-04-10T12:00:00.000+09:30</published>
    <summary type="html">A workaround for a fatal error while trying to debug a .NET 10 application in Visual Studio</summary>
    <content type="html">&lt;p&gt;Trying to debug an &lt;a href=&quot;https://aspire.dev&quot;&gt;Aspire&lt;/a&gt; .NET 10 application in Visual Studio today and hitting this error:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/visual-studio-fatal-error.B1dWqQi6_Zzh0OH.webp&quot; alt=&quot;Visual Studio error dialog&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A fatal error has occurred and debugging needs to be terminated. For more details, please see the Microsoft Help and Support web site. HRESULT=0x8013132d. ErrorCode=0x0.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A bit of searching online turned up &lt;a href=&quot;https://developercommunity.microsoft.com/t/11022347&quot;&gt;this bug report&lt;/a&gt; on the Microsoft Developer Community site.&lt;/p&gt;
&lt;p&gt;That was eventually forwarded to the &lt;a href=&quot;https://github.com/dotnet/runtime/issues/124913&quot;&gt;.NET runtime&lt;/a&gt; repo on GitHub where it was identified as relating to a &lt;a href=&quot;https://learn.microsoft.com/dotnet/core/compatibility/interop/9.0/cet-support?WT.mc_id=DOP-MVP-5001655&quot;&gt;breaking change introduced in .NET 9&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This change improves the security of .NET applications, but in this case is stopping me from being able to debug.&lt;/p&gt;
&lt;p&gt;You can opt out of this new behaviour by adding the following property to your csproj:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;CETCompat&amp;gt;false&amp;lt;/CETCompat&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Given in my case the problem only happens when I&apos;m debugging, then it would be preferable to leave this new feature on by default, so I&apos;ve added a condition to the property like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;CETCompat Condition=&quot;&apos;$(Configuration)&apos; == &apos;Debug&apos;&quot;&amp;gt;false&amp;lt;/CETCompat&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In my Aspire AppHost csproj file, the &lt;code&gt;PropertyGroup&lt;/code&gt; element looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  &amp;lt;PropertyGroup&amp;gt;
    &amp;lt;OutputType&amp;gt;Exe&amp;lt;/OutputType&amp;gt;
    &amp;lt;TargetFramework&amp;gt;net10.0&amp;lt;/TargetFramework&amp;gt;
    &amp;lt;ImplicitUsings&amp;gt;enable&amp;lt;/ImplicitUsings&amp;gt;
    &amp;lt;Nullable&amp;gt;enable&amp;lt;/Nullable&amp;gt;
    &amp;lt;UserSecretsId&amp;gt;00000000-0000-0000-0000-000000000000&amp;lt;/UserSecretsId&amp;gt;
    &amp;lt;CETCompat Condition=&quot;&apos;$(Configuration)&apos; == &apos;Debug&apos;&quot;&amp;gt;false&amp;lt;/CETCompat&amp;gt;
  &amp;lt;/PropertyGroup&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/visual-studio-2026.VSY9MLYp.png" width="256" height="256"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/visual-studio-2026.VSY9MLYp.png" width="256" height="256"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/04/exceptional-unit-tests</id>
    <updated>2026-04-01T09:30:00.000+10:30</updated>
    <title>Exceptional unit tests</title>
    <link href="https://david.gardiner.net.au/2026/04/exceptional-unit-tests" rel="alternate" type="text/html" title="Exceptional unit tests"/>
    <category term=".NET"/>
    <category term="Testing"/>
    <published>2026-04-01T09:30:00.000+10:30</published>
    <summary type="html">Unexpected exceptions being thrown and caught inside application
code, that weren&apos;t obvious until the unit test was run under a debugger.</summary>
    <content type="html">&lt;p&gt;I was working on a .NET application that had a nice suite of unit tests, and pleasingly the tests were all passing.&lt;/p&gt;
&lt;p&gt;While making a change to the code, one of the tests failed (which is the whole point of having tests!). To better understand the reason for the failure I re-ran the test in the Visual Studio debugger.&lt;/p&gt;
&lt;p&gt;I noticed something strange - the system under test was throwing a &lt;code&gt;NullReferenceException&lt;/code&gt;, which was then being caught (and effectively swallowed) by an outer &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt; block.&lt;/p&gt;
&lt;p&gt;The surprising thing was that this test should not have been doing that - a pretty straightforward test of some business logic. It was just a coincidence that in this case the exception wasn&apos;t changing the observable behaviour of the code, which is why the test had previously been passing.&lt;/p&gt;
&lt;p&gt;In this case, the underlying cause of the &lt;code&gt;NullReferenceException&lt;/code&gt; turned out to be a missing mocked method on a dependency.&lt;/p&gt;
&lt;p&gt;It did make me wonder though, are there other similar issues hidden elsewhere in the unit tests?&lt;/p&gt;
&lt;p&gt;To find out, I opened up Visual Studio&apos;s &lt;a href=&quot;https://learn.microsoft.com/visualstudio/debugger/managing-exceptions-with-the-debugger?view=visualstudio&amp;amp;WT.mc_id=DOP-MVP-5001655&quot;&gt;Exception Settings window&lt;/a&gt; (&lt;strong&gt;Debug&lt;/strong&gt; | &lt;strong&gt;Windows&lt;/strong&gt; | &lt;strong&gt;Exception Settings&lt;/strong&gt;), searched for &apos;NullReferenceException&apos; and ensured that it was set to &apos;Break when thrown&apos;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/exception-settings.BskstRPv_Z1zU8Gz.webp&quot; alt=&quot;Screenshot of Exception Settings window in Visual Studio&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I then ran the entire test suite under the debugger (&lt;strong&gt;Test&lt;/strong&gt; | &lt;strong&gt;Debug All Tests&lt;/strong&gt;) and took note of each time the debugger stopped with a thrown exception. Some of these were other exception types that were expected (and I could turn off the &apos;Break when thrown&apos; on those if they were too noisy).&lt;/p&gt;
&lt;p&gt;I ended up finding a few other tests that had similar issues. There were also some paths in the application code where null handling could be made more robust.&lt;/p&gt;
&lt;p&gt;The tests still pass, but they should now be a bit more reliable in the future for the next developer who is relying on them when making application code changes (which could be me!)&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/exception-settings.BskstRPv.png" width="942" height="498"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/exception-settings.BskstRPv.png" width="942" height="498"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/03/csharp-extension-members</id>
    <updated>2026-03-17T08:00:00.000+10:30</updated>
    <title>Best of C# 14 - extension members</title>
    <link href="https://david.gardiner.net.au/2026/03/csharp-extension-members" rel="alternate" type="text/html" title="Best of C# 14 - extension members"/>
    <category term=".NET"/>
    <published>2026-03-17T08:00:00.000+10:30</published>
    <summary type="html">We&apos;ve had extension methods in C# for a long time, but what are Extension Members?</summary>
    <content type="html">&lt;p&gt;Extension methods were introduced way back in 2007 with &lt;a href=&quot;https://learn.microsoft.com/dotnet/csharp/whats-new/csharp-version-history?WT.mc_id=DOP-MVP-5001655#c-version-30&quot;&gt;C# 3&lt;/a&gt;. But they only allowed you to add methods to an instance type. Extension Members, a new feature of C# 14, finally &apos;extend&apos; this concept to properties and static members too.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf_Z2mqclD.webp&quot; alt=&quot;C# logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The classic extension method definition looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static class MyExtensions
{
    public static int WordCount(this string str) =&amp;gt;
        str.Split([&apos; &apos;, &apos;.&apos;, &apos;?&apos;], StringSplitOptions.RemoveEmptyEntries).Length;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The class needed to be static, and the static method&apos;s first parameter needs the &lt;code&gt;this&lt;/code&gt; modifier.&lt;/p&gt;
&lt;p&gt;That style of extension method hasn&apos;t gone away, and you&apos;re free to keep doing it that way if you like.&lt;/p&gt;
&lt;p&gt;But now we can &lt;a href=&quot;https://learn.microsoft.com/dotnet/csharp/whats-new/csharp-14?WT.mc_id=DOP-MVP-5001655#extension-members&quot;&gt;create extension properties or extension methods on types&lt;/a&gt;. To do that there&apos;s a new  &lt;code&gt;extension&lt;/code&gt; keyword that is used to create extension blocks. You can use these for extension properties, and extension methods, and also for both of these for types in addition to instances.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static class Enumerable
{
    // Extension block
    extension(string str) // extension members for string instances
    {
        // Extension method:
        public int WordCount() =&amp;gt;
            str.Split([&apos; &apos;, &apos;.&apos;, &apos;?&apos;], StringSplitOptions.RemoveEmptyEntries).Length;

        // Extension property:
        public bool Is80CharsLong =&amp;gt;
            str.Length == 80;
    }    

    extension(string)
    {
        // extension method
        public static string ToTitleCase(string str) =&amp;gt;
            System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(str);

        // extension property
        public static string TwoSpaces =&amp;gt; &quot;  &quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You use these extension members like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var sentence = &quot;This is a sentence of words.&quot;;

int wordCount = sentence.WordCount();

var is80CharsLong = sentence.Is80CharsLong;

var title = string.ToTitleCase(&quot;hello world&quot;);

var indent = string.TwoSpaces;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&apos;s another useful language tool. Used appropriately it could make your codebase easier to understand and allow you to separate concerns.&lt;/p&gt;
&lt;h2&gt;Possible reasons for not using an &lt;code&gt;extension&lt;/code&gt; block&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;I&apos;ve read that some library authors encountered issues when they migrated their old extension methods to extension blocks and the libraries were targeting older frameworks besides .NET 10. If you need to target older frameworks then test this thoroughly first.&lt;/li&gt;
&lt;li&gt;If all you&apos;re doing is creating extension methods (no properties or type methods), then it&apos;s fine to stick with the old syntax.&lt;/li&gt;
&lt;/ul&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf.png" width="72" height="72"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf.png" width="72" height="72"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2026/03/csharp-field-keyword</id>
    <updated>2026-03-10T22:30:00.000+10:30</updated>
    <title>Best of C# 14 - field keyword</title>
    <link href="https://david.gardiner.net.au/2026/03/csharp-field-keyword" rel="alternate" type="text/html" title="Best of C# 14 - field keyword"/>
    <category term=".NET"/>
    <published>2026-03-10T22:30:00.000+10:30</published>
    <summary type="html">The new &apos;field&apos; keyword is in C# 14. When can you use it and when might you not.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&quot;https://learn.microsoft.com/dotnet/csharp/whats-new/csharp-14?WT.mc_id=DOP-MVP-5001655&quot;&gt;C# 14&lt;/a&gt; shipped back in November last year along with .NET 10. I&apos;ve already given a presentation a number of times on my highlights of .NET 10, which included three features of C# 14 that I think are particularly useful.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf_Z2mqclD.webp&quot; alt=&quot;C# logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The first I&apos;m going to focus on is the new &lt;code&gt;field&lt;/code&gt; keyword.&lt;/p&gt;
&lt;p&gt;The problem this solves is where you&apos;d really like to avoid having to declare a backing field, but prior to C# 14 as soon as you wanted to include any kind of logic in a getter or setter, then you couldn&apos;t use &lt;a href=&quot;https://learn.microsoft.com/dotnet/csharp/programming-guide/classes-and-structs/auto-implemented-properties?WT.mc_id=DOP-MVP-5001655&quot;&gt;auto properties&lt;/a&gt;, but instead you needed to provide a backing field and implement the getter and setter. eg.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private string _msg;
public string Message
{
    get =&amp;gt; _msg;
    set =&amp;gt; _msg = value ?? throw new ArgumentNullException(nameof(value));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because we&apos;re adding validation to the setter, that forced us to introduce the &lt;code&gt;_msg&lt;/code&gt; field. And if you can be disciplined that&apos;s fine, but the trap is there&apos;s nothing to stop other code in the same class from also referencing that, even if it shouldn&apos;t. The compiler won&apos;t stop you.&lt;/p&gt;
&lt;p&gt;To help with scenarios like this, and to reduce the amount of code you need to write, C# 14 introduces the &lt;code&gt;field&lt;/code&gt; keyword. It&apos;s a way to reference the compiler-generated backing field, but only within the property&apos;s getter and setter. Our code now becomes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public string Message
{
    get;
    set =&amp;gt; field = value ?? throw new ArgumentNullException(nameof(value));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our line count is one less, plus we&apos;re now not exposing the backing field outside of the property.&lt;/p&gt;
&lt;h2&gt;Under the hood&lt;/h2&gt;
&lt;p&gt;When the C# compiler turns the original code into IL (Intermediate Language), it creates something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.field private string _msg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s how IL represents a regular field.&lt;/p&gt;
&lt;p&gt;And if you search the IL generated by code that uses the &lt;code&gt;field&lt;/code&gt; keyword, you&apos;ll still see a &lt;code&gt;.field&lt;/code&gt; entry, but the name is a bit different:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.field private string &apos;&amp;lt;Message2&amp;gt;k__BackingField&apos;
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
      = (01 00 00 00 )
    .custom instance void [System.Runtime]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Runtime]System.Diagnostics.DebuggerBrowsableState)
      = (01 00 00 00 00 00 00 00 )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&amp;lt;Message2&amp;gt;k__BackingField&lt;/code&gt; is not a valid C# identifier, so there&apos;s no chance of being able to reference this field by name elsewhere in your C# code. That &lt;code&gt;DebuggerBrowsableAttribute&lt;/code&gt; also means that your debugger will choose to not show the field.&lt;/p&gt;
&lt;p&gt;But because the compiler knows the name it has assigned to the field, it generates the correct IL for you when you use the &lt;code&gt;field&lt;/code&gt; keyword.&lt;/p&gt;
&lt;p&gt;Here&apos;s the IL generated for the property setter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;IL_0000: ldarg.0      // this
IL_0001: ldarg.1      // &apos;value&apos;
IL_0002: dup

IL_0003: brtrue.s     IL_0011
IL_0005: pop
IL_0006: ldstr        &quot;value&quot;
IL_000b: newobj       instance void [System.Runtime]System.ArgumentNullException::.ctor(string)
IL_0010: throw
IL_0011: stfld        string Stuff::&apos;&amp;lt;Message&amp;gt;k__BackingField&apos;
IL_0016: ret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://learn.microsoft.com/dotnet/api/system.reflection.emit.opcodes.stfld?view=net-10.0&amp;amp;WT.mc_id=DOP-MVP-5001655&quot;&gt;&lt;code&gt;stfld&lt;/code&gt;&lt;/a&gt; instruction is saving the current value from the stack into the the compiler-generated backing field.&lt;/p&gt;
&lt;h2&gt;Reasons for not using &lt;code&gt;field&lt;/code&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;If you have legitimate reasons for accessing the backing field elsewhere in the class (such as multiple properties that reference or share the same backing field).&lt;/li&gt;
&lt;li&gt;Thread safety using &lt;code&gt;volatile&lt;/code&gt;, &lt;code&gt;Interlocked&lt;/code&gt; or &lt;code&gt;lock&lt;/code&gt; patterns.&lt;/li&gt;
&lt;li&gt;Lazy initialisation/caching patterns&lt;/li&gt;
&lt;li&gt;Serialization or reflection that makes assumptions about field names&lt;/li&gt;
&lt;/ul&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf.png" width="72" height="72"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/logo_csharp.CqWbN3Rf.png" width="72" height="72"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2025/11/aspire-without-dotnet</id>
    <updated>2025-11-17T08:00:00.000+10:30</updated>
    <title>Aspire with Python, React, Rust and Node apps</title>
    <link href="https://david.gardiner.net.au/2025/11/aspire-without-dotnet" rel="alternate" type="text/html" title="Aspire with Python, React, Rust and Node apps"/>
    <category term=".NET"/>
    <category term="Aspire"/>
    <category term="Talks"/>
    <published>2025-11-17T08:00:00.000+10:30</published>
    <summary type="html">Using Aspire to build a distributed application with Python, React, Rust and Node.js components</summary>
    <content type="html">&lt;p&gt;Aspire (formerly .NET Aspire) is a great way to create observable, production-ready distributed apps by defining a code-based model of services, resources, and connections. It simplifies local development and debugging, as well as deployment.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-logo-256.CA6LsmXl_2gR0dV.webp&quot; alt=&quot;Aspire logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;By their very nature, distributed applications will have at least a few (if not a lot) of components. This presents a challenge both for the local developer experience and for deployment. Aspire seeks to simplify this by allowing you to model the services, resources, and connections in code. With one command you can then not only launch everything locally, but ensure that each service knows how to connect to the other services it needs to function.&lt;/p&gt;
&lt;p&gt;You can use it for both development and deployment, or if you already have an existing deployment process you&apos;re happy with (eg. Infrastructure as Code/deployment pipelines) you can just use Aspire to simply your local development experience.&lt;/p&gt;
&lt;p&gt;Check out my previous post &lt;a href=&quot;/2025/11/aspire&quot;&gt;Introducing Aspire&lt;/a&gt; for an overview of what Aspire is and how it works.&lt;/p&gt;
&lt;p&gt;As part of this year&apos;s .NET Conf virtual conference, I presented a talk &quot;Taking .NET out of .NET Aspire - working with non-.NET applications&quot;, in which I show how despite Aspire being written in .NET, it can integrate with a whole range of other software languages ecosystems. Here&apos;s the recording of that presentation:&lt;/p&gt;

&lt;p&gt;One issue with the talk was that it had to be pre-recorded a couple of weeks beforehand, so it was done using the Aspire 9.5 bits. Now that Aspire 13.0 is out, some of the packages that I used from the Community Toolkit in the demo are no longer necessary as Aspire has improved the integration with Python and Node.js applications.&lt;/p&gt;
&lt;p&gt;The source code for the demo can be found at &lt;a href=&quot;https://github.com/flcdrg/aspire-non-dotnet&quot;&gt;https://github.com/flcdrg/aspire-non-dotnet&lt;/a&gt;. A quick glance and you might think &quot;there&apos;s no .NET or Aspire in this repo&quot; and if you just look at the &lt;code&gt;main&lt;/code&gt; branch then you&apos;d be right. There are separate branches where I incrementally integrate each component into Aspire.&lt;/p&gt;
&lt;p&gt;The scenario I demo is a &apos;Pet supplies&apos; e-commerce website, with a React front-end, Python backend API running with MongoDB. The Python backend also talks to a Rust-based payment gateway service, and as a bonus step right at the end I add a Node.js server app to provide random pet jokes!&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-petstore-web.NuLNUsqZ_Z2ud4JN.webp&quot; alt=&quot;Screenshot of Pet supplies web page&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The technology mix is not unheard of, particularly at some larger organisations where you may have different teams working on different features or services and sometimes they are allowed enough autonomy to choose their own technology stack. Whether this is a case of &quot;using the right tool for the job&quot; or &quot;must use the current shiny new thing&quot; isn&apos;t so important. The reality is that for good or other reasons you often find yourself in this situation.&lt;/p&gt;
&lt;h2&gt;MongoDB&lt;/h2&gt;
&lt;p&gt;MongoDB is supported out of the box in Aspire. The original process was to launch this via &lt;code&gt;docker compose&lt;/code&gt;. Aspire actually &lt;a href=&quot;https://aspire.dev/integrations/compute/docker/&quot;&gt;supports compose files too (via the Aspire.Hosting.Docker package)&lt;/a&gt;, but in this case I&apos;m taking advantage of the &lt;a href=&quot;https://aspire.dev/integrations/databases/mongodb/&quot;&gt;&lt;code&gt;Aspire.Hosting.MongoDB&lt;/code&gt;&lt;/a&gt; to configure the MongoDB service, database, and for bonus points, include the Mongo Express management UI. This will still run in a Docker container, but Aspire handles all the configuration for me.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;var mongo = builder.AddMongoDB(&quot;mongo&quot;)
    .WithDataVolume()
    .WithMongoExpress();

var mongodb = mongo.AddDatabase(&quot;petstore&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;WithDataVolume()&lt;/code&gt; means that a Docker volume is created to persist the database data between runs.&lt;/p&gt;
&lt;p&gt;The original implementation also has a PowerShell script to populate the database with sample data. I wired up that script so that it gets run automatically when the MongoDB service starts up in Aspire.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;var loadData = builder.AddExecutable(&quot;load-data&quot;, &quot;pwsh&quot;, &quot;../mongodb&quot;, &quot;-noprofile&quot;, &quot;./populate.ps1&quot;)
    .WaitFor(mongo)
    .WithArgs(&quot;-connectionString&quot;)
    .WithArgs(new ConnectionStringReference(mongo.Resource, false));
//.WithExplicitStart();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script conveniently already had a parameter defined to pass in a connection string, so I take advantage of that.&lt;/p&gt;
&lt;p&gt;If you&apos;d prefer to just run the script manually (rather than every time you start Aspire) you could uncomment the &lt;code&gt;.WithExplicitStart()&lt;/code&gt; method.&lt;/p&gt;
&lt;h2&gt;Rust&lt;/h2&gt;
&lt;p&gt;I&apos;ve never used the Rust programming language before, but it is becoming increasingly popular, especially where you might previously have used C or C++. Rust uses &lt;a href=&quot;https://doc.rust-lang.org/cargo/&quot;&gt;Cargo&lt;/a&gt; as its package manager and build tool. Support for building and running Rust applications is provided by the &lt;a href=&quot;https://aspire.dev/integrations/frameworks/rust/&quot;&gt;Community Toolkit&apos;s CommunityToolkit.Aspire.Hosting.Rust package&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Configuring the Rust application is quite straightforward with the &lt;code&gt;AddRustApp&lt;/code&gt; method. The application has a default port it listens on but allows that to be overridden via the &lt;code&gt;PAYMENT_API_PORT&lt;/code&gt; environment variable. Aspire will set that to the appropriate port number by the call to &lt;code&gt;WithHttpEndpoint&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;var rust = builder.AddRustApp(&quot;rustpaymentapi&quot;, &quot;../RustPaymentApi&quot;, [])
    .WithHttpEndpoint(env: &quot;PAYMENT_API_PORT&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Node.js&lt;/h2&gt;
&lt;p&gt;JavaScript is well known as a front end language, but platforms like Node.js allow you to write server-side applications in JavaScript (and TypeScript) too. Aspire 13.0 introduces improved support for JavaScript apps via a new &lt;a href=&quot;https://www.nuget.org/packages/Aspire.Hosting.JavaScript&quot;&gt;Aspire.Hosting.JavaScript package&lt;/a&gt;. This includes the ability to configure using &lt;code&gt;pnpm&lt;/code&gt; or &lt;code&gt;yarn&lt;/code&gt; package managers (the default being &lt;code&gt;npm&lt;/code&gt;).&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;var nodeApp = builder.AddJavaScriptApp(&quot;node-joke-api&quot;, &quot;../NodeApp&quot;, &quot;start&quot;)
    .WithPnpm()
    // If you are using fnm for Node.js version management, you might need to adjust the PATH
    .WithEnvironment(&quot;PATH&quot;, Environment.GetEnvironmentVariable(&quot;PATH&quot;) + &quot;;&quot; + Environment.ExpandEnvironmentVariables(@&quot;%USERPROFILE%\AppData\Roaming\fnm\aliases\default&quot;))
    .WithHttpEndpoint(env: &quot;PORT&quot;)
    .WithOtlpExporter();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I use &lt;a href=&quot;https://github.com/Schniz/fnm&quot;&gt;&lt;code&gt;fnm&lt;/code&gt;&lt;/a&gt; (Fast Node Manager) to manage my Node.js versions. This means that the actual &lt;code&gt;node&lt;/code&gt; executable is not in the PATH by default, but rather is added (or updated) dynamically as I change directory via my PowerShell profile script. Because that script isn&apos;t run by Aspire, I explicity append the &lt;code&gt;fnm&lt;/code&gt; default alias directory to the PATH environment variable so that &lt;code&gt;node&lt;/code&gt; can be found.&lt;/p&gt;
&lt;h2&gt;Python&lt;/h2&gt;
&lt;p&gt;Python is an interesting ecosystem. There are a number of different package managers that will influence how you work with it. Aspire has first-class support for Python applications that use &lt;code&gt;pip&lt;/code&gt; via the &lt;code&gt;Aspire.Hosting.Python&lt;/code&gt; NuGet package. (See &lt;a href=&quot;https://aspire.dev/get-started/first-app/?lang=python&quot;&gt;Python apps in Aspire&lt;/a&gt; for more details).&lt;/p&gt;
&lt;p&gt;I recently worked on a client engagement where they were using &lt;a href=&quot;https://docs.astral.sh/uv/&quot;&gt;&lt;code&gt;uv&lt;/code&gt;&lt;/a&gt; with their Python applications. Aspire 13.0 now includes direct support for &lt;code&gt;uv&lt;/code&gt; (via &lt;code&gt;WithUv()&lt;/code&gt;) and &lt;code&gt;uvicorn&lt;/code&gt; (with &lt;code&gt;AddUvicornApp()&lt;/code&gt;):&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;var pythonApp = builder.AddUvicornApp(&quot;python-api&quot;, &quot;../PythonUv&quot;, &quot;src.api:app&quot;)
    .WithUv()
    .WaitFor(mongo)
    .WaitFor(rust)
    .WaitFor(nodeApp)
    .WithEnvironment(&quot;PYTHONIOENCODING&quot;, &quot;utf-8&quot;)
    .WithEnvironment(&quot;MONGO_CONNECTION_STRING&quot;, new ConnectionStringReference(mongo.Resource, false))
    .WithEnvironment(&quot;PAYMENT_API_BASE_URL&quot;, new EndpointReference(rust.Resource, &quot;http&quot;))
    .WithEnvironment(&quot;NODE_APP_BASE_URL&quot;, ReferenceExpression.Create($&quot;{nodeApp.Resource.GetEndpoint(&quot;http&quot;)}&quot;))
    .WithHttpHealthCheck(&quot;/&quot;)
    .WithExternalHttpEndpoints();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We wait for the MongoDB service, the Rust payment service, and the Node.js joke service, so that they have all started before the Python app. We also set up environment variables to pass in the connection strings and endpoints that the Python app needs to connect to those services.&lt;/p&gt;
&lt;h2&gt;A Vite React Frontend web app&lt;/h2&gt;
&lt;p&gt;The same &lt;code&gt;Aspire.Hosting.JavaScript&lt;/code&gt; package that we used to wire up the Node.js application can also be used for the frontend web app. Helpfully, it specifically includes support for Vite apps&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;var web = builder.AddViteApp(&quot;web&quot;, &quot;../web-vite-react&quot;)
    .WithPnpm()
    // If you are using fnm for Node.js version management, you might need to adjust the PATH
    .WithEnvironment(&quot;PATH&quot;, Environment.GetEnvironmentVariable(&quot;PATH&quot;) + &quot;;&quot; + Environment.ExpandEnvironmentVariables(@&quot;%USERPROFILE%\AppData\Roaming\fnm\aliases\default&quot;))
    .WaitFor(pythonApp)
    .WithEnvironment(&quot;VITE_API_BASE_URL&quot;, new EndpointReference(pythonApp.Resource, &quot;http&quot;));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Again, because I use &lt;code&gt;fnm&lt;/code&gt; to manage my Node.js versions, I need to append the &lt;code&gt;fnm&lt;/code&gt; default alias directory to the PATH environment variable so that &lt;code&gt;node&lt;/code&gt; can be found.&lt;/p&gt;
&lt;p&gt;The frontend application only talks to the Python backend API, so we enusre that service has started first, and set up the &lt;code&gt;VITE_API_BASE_URL&lt;/code&gt; environment variable so that the Vite app knows where to find the API.&lt;/p&gt;
&lt;p&gt;And with that in place, we can run the entire distributed application locally with a single command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotnet run --project ./AspireAppHost/AspireAppHost.csproj --launch-profile http
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not all of the applications I&apos;m using here support HTTPS self-signed development certificates, so I stick with running everything over HTTP for now. This is something that Aspire has improved on in 13.0. Obviously in a production deployment you&apos;d want to use HTTPS everywhere. If I didn&apos;t need to set the launch profile, then I could use the Aspire CLI instead:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;aspire run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The nice thing about all this is I didn&apos;t need to make any changes any of the front end or back end applications to get them to work with Aspire. All the configuration was done in the Aspire host application. This assumes that your applications have provided a &apos;seam&apos; (eg. environment variables or command line arguments) to allow you to configure things like connection strings, ports, and endpoints.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-petstore-resources.CkupUbS-_Z1nuOUe.webp&quot; alt=&quot;Screenshot of Aspire dashboard showing resources page&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Here&apos;s the final version of AppHost.cs:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;var builder = DistributedApplication.CreateBuilder(args);

// MongoDB
var mongo = builder.AddMongoDB(&quot;mongo&quot;)
    .WithDataVolume()
    .WithMongoExpress();

var mongodb = mongo.AddDatabase(&quot;petstore&quot;);

var loadData = builder.AddExecutable(&quot;load-data&quot;, &quot;pwsh&quot;, &quot;../mongodb&quot;, &quot;-noprofile&quot;, &quot;./populate.ps1&quot;)
    .WaitFor(mongo)
    .WithArgs(&quot;-connectionString&quot;)
    .WithArgs(new ConnectionStringReference(mongo.Resource, false));
//.WithExplicitStart();

// Rust service
var rust = builder.AddRustApp(&quot;rustpaymentapi&quot;, &quot;../RustPaymentApi&quot;, [])
    .WithHttpEndpoint(env: &quot;PAYMENT_API_PORT&quot;);

// Node.js App
var nodeApp = builder.AddJavaScriptApp(&quot;node-joke-api&quot;, &quot;../NodeApp&quot;, &quot;start&quot;)
    .WithPnpm()
    // If you are using fnm for Node.js version management, you might need to adjust the PATH
    .WithEnvironment(&quot;PATH&quot;, Environment.GetEnvironmentVariable(&quot;PATH&quot;) + &quot;;&quot; + Environment.ExpandEnvironmentVariables(@&quot;%USERPROFILE%\AppData\Roaming\fnm\aliases\default&quot;))
    .WithHttpEndpoint(env: &quot;PORT&quot;)
    .WithOtlpExporter();

// Python API
var pythonApp = builder.AddUvicornApp(&quot;python-api&quot;, &quot;../PythonUv&quot;, &quot;src.api:app&quot;)
    .WithUv()
    .WaitFor(mongo)
    .WaitFor(rust)
    .WaitFor(nodeApp)
    .WithEnvironment(&quot;PYTHONIOENCODING&quot;, &quot;utf-8&quot;)
    .WithEnvironment(&quot;MONGO_CONNECTION_STRING&quot;, new ConnectionStringReference(mongo.Resource, false))
    .WithEnvironment(&quot;PAYMENT_API_BASE_URL&quot;, new EndpointReference(rust.Resource, &quot;http&quot;))
    .WithEnvironment(&quot;NODE_APP_BASE_URL&quot;, ReferenceExpression.Create($&quot;{nodeApp.Resource.GetEndpoint(&quot;http&quot;)}&quot;))
    .WithHttpHealthCheck(&quot;/&quot;)
    .WithExternalHttpEndpoints();

// Frontend
var web = builder.AddViteApp(&quot;web&quot;, &quot;../web-vite-react&quot;)
    .WithPnpm()
    // If you are using fnm for Node.js version management, you might need to adjust the PATH
    .WithEnvironment(&quot;PATH&quot;, Environment.GetEnvironmentVariable(&quot;PATH&quot;) + &quot;;&quot; + Environment.ExpandEnvironmentVariables(@&quot;%USERPROFILE%\AppData\Roaming\fnm\aliases\default&quot;))
    .WaitFor(pythonApp)
    .WithEnvironment(&quot;VITE_API_BASE_URL&quot;, new EndpointReference(pythonApp.Resource, &quot;http&quot;));

builder.Build().Run();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To get the full benefit of Aspire you will want to take a look at adding OpenTelemetry instrumentation. If you already have that in place then there&apos;s probably nothing to change. Aspire will set a bunch of OpenTelemetry-related environment variables for each application. If that&apos;a new thing then you&apos;ll get the double benefit of then being able to take advantage of that telemetry when your applications are deployed to production too!&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-petstore-traces.ZXy-RHuR_Z1wOzaQ.webp&quot; alt=&quot;Screenshot of Aspire dashboard showing traces page&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-petstore-metrics.B07KqI6V_Z1h2lSh.webp&quot; alt=&quot;Screenshot of Aspire dashboard showing metrics page&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Aspire has the potential to greatly simplify and improve the local development experience for distributed applications - potentially removing the need for numerous scripts and manual steps for a developer to get up and running much more quickly. It&apos;s also a tool that can benefit almost every development team, regardless of the technology stack they are using.&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/aspire-logo-256.CA6LsmXl.png" width="256" height="256"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/aspire-logo-256.CA6LsmXl.png" width="256" height="256"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2025/11/aspire</id>
    <updated>2025-11-12T09:00:00.000+10:30</updated>
    <title>Introducing Aspire</title>
    <link href="https://david.gardiner.net.au/2025/11/aspire" rel="alternate" type="text/html" title="Introducing Aspire"/>
    <category term=".NET"/>
    <category term="Aspire"/>
    <published>2025-11-12T09:00:00.000+10:30</published>
    <summary type="html">What is Aspire, and how can it help you orchestrate your local development experience
by modelling your application architecture, and providing a really cool dashboard (among other things)?</summary>
    <content type="html">&lt;p&gt;&lt;a href=&quot;https://aspire.dev/&quot;&gt;Aspire&lt;/a&gt; is a really interesting tool for orchestrating your local development experience, integrating with OpenTelemetry, and optionally providing a way to automate deployments. It has the potential to replace manual steps and custom startup scripts with a more declarative and simpler approach.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-logo-256.CA6LsmXl_2gR0dV.webp&quot; alt=&quot;Aspire logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Originally known as &apos;.NET Aspire&apos;, the name has recently been tweaked to just &apos;Aspire&apos; to help promote it&apos;s applicability across all kinds of development environments, not just .NET.&lt;/p&gt;
&lt;p&gt;Today the latest version of Aspire was demonstrated at &lt;a href=&quot;https://www.dotnetconf.net/agenda&quot;&gt;.NET Conf 2025&lt;/a&gt;. Let&apos;s take a look at what Aspire is, and how it can help you.&lt;/p&gt;
&lt;h2&gt;The AppHost&lt;/h2&gt;
&lt;p&gt;At the heart of Aspire is the concept of an &apos;AppHost&apos;. This is a .NET project in which you define the services (which could be local applications, containers and even remote services). You also define any dependencies between those services, and can use that to then provide configuration information (connection strings, endpoint URLs etc) so that they can communicate with each other. You&apos;re essentially creating a model of your application architecture.&lt;/p&gt;
&lt;p&gt;Importantly, while the AppHost is a .NET project (possibly even a .NET 10 file-based application to make it really simple), this is the only thing that needs .NET. All the other services can be written in pretty much any language or framework you like. They could be Node.js applications, Python, Java, or anything else (including .NET of course!).&lt;/p&gt;
&lt;p&gt;Here&apos;s an example AppHost file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var builder = DistributedApplication.CreateBuilder(args);

var mongo = builder.AddMongoDB(&quot;mongo&quot;)
    .WithDataVolume();

var api = builder.AddProject&amp;lt;Projects.aspire_starter_ApiService&amp;gt;(&quot;apiservice&quot;)
    .WithReference(mongo)
    .WaitFor(mongo);

var frontend = builder.AddViteApp(&quot;frontend&quot;, &quot;../frontend&quot;)
    .WithPnpm()
    .WithReference(api)
    .WaitFor(api);

builder.Build().Run();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In just those ~15 lines of code, we&apos;re describing an application which has a MongoDB database, a .NET API service, and a Vite frontend web app:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;We create a &lt;code&gt;builder&lt;/code&gt; variable (an instance of &lt;code&gt;IDistributedApplicationBuilder&lt;/code&gt;) that will then use to define and wire up the services.&lt;/li&gt;
&lt;li&gt;The MongoDB database is hosted as a Docker container, with a data volume to persist data between runs.&lt;/li&gt;
&lt;li&gt;The API service is a .NET project that references the MongoDB service and waits for it to be ready before starting.&lt;/li&gt;
&lt;li&gt;The frontend is a Vite application that references the API service and exposes an HTTP endpoint.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;With that in place, all you need to do is run this AppHost project (either with &lt;code&gt;dotnet run&lt;/code&gt; or via the Aspire CLI with &lt;code&gt;aspire run&lt;/code&gt;), and Aspire will figure out the order to start the services, then start them up while providing the requested configuration information.&lt;/p&gt;
&lt;h3&gt;WaitFor&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;WaitFor&lt;/code&gt; method tells Aspire that this service should wait until the referenced service is in a &apos;running&apos; state. Services may define health checks that Aspire will use to determine when a service is truly ready. SQL Server is a good example - where even though the main SQL Server process may have started, the database engine won&apos;t be ready immediately as there are a number of housekeeping tasks it needs to complete before all the databases become available for use.&lt;/p&gt;
&lt;h3&gt;WithReference&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;WithReference&lt;/code&gt; method causes Aspire to inject environment variables into the service which provide a way for it to connect to the referenced service.&lt;/p&gt;
&lt;p&gt;For the above code, the references to Mongo generate connection string environment variable of the form &lt;code&gt;ConnectionStrings__mongo&lt;/code&gt;. The API service can use that environment variable to connect to the MongoDB database.&lt;/p&gt;
&lt;p&gt;The reference to the API service generate environment variables &lt;code&gt;APISERVICE_HTTP&lt;/code&gt; and &lt;code&gt;APISERVICE_HTTPS&lt;/code&gt; which provide the URLs that the frontend can use to connect to the API.&lt;/p&gt;
&lt;p&gt;There are overloads of &lt;code&gt;WithReference&lt;/code&gt; that allow you to customise naming scheme of the environment variables, and if that isn&apos;t enough you can always add a custom environment variable with &lt;code&gt;WithEnvironment&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;The dashboard&lt;/h2&gt;
&lt;p&gt;One of the key features of Aspire is that you&apos;re not just left to wonder how things are going. A really cool dashboard is provided that gives you both the high-level overview of all your services, as well as being able to drill into specific service configuration and logs, and even see OpenTelemetry traces and metrics (if you have OpenTelemetry added to your services).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-starter-dashboard.YGmYruy3_Zxr80d.webp&quot; alt=&quot;Aspire dashboard showing running resources&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Clicking on the &apos;frontend&apos; service shows the configuration that Aspire has provided to that service, including the environment variables that were injected:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-starter-service-details.LwT7lW-j_Z1N3e7e.webp&quot; alt=&quot;Viewing a service&apos;s configuration&quot; /&gt;&lt;/p&gt;
&lt;p&gt;View all the console output from services (or filter to just a specific service):&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-starter-console.Ba6dWfxd_Z7b1R3.webp&quot; alt=&quot;Aspire dashboard showing aggregated console output&quot; /&gt;&lt;/p&gt;
&lt;p&gt;If your services are instrumented with OpenTelemetry, you can see structured logs, traces and metrics:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-starter-structured-logging.D8djTE9C_15411K.webp&quot; alt=&quot;Aspire dashboard showing structured logging&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-starter-traces.CyGBFzle_ZdYPRv.webp&quot; alt=&quot;Aspire dashboard showing traces&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-starter-metrics.DuU2P7n4_mqBU7.webp&quot; alt=&quot;Aspire dashboard showing metrics&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Aspire is resilient enough that for example if you forget to start Docker, it will let you know that services depending on that can&apos;t start.
&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-dashboard-unhealthy.u_OIjb65_Z29pbV8.webp&quot; alt=&quot;Aspire dashboard showing unhealthy state&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The cool thing though is you don&apos;t need to restart Aspire. If you start Docker, Aspire will notice that it is now available, and will start the services that were waiting on it. You can see the status change in real-time in the dashboard as services become healthy. Note that the other services (which need to wait for Mongo) are not even started yet.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/aspire-dashboard-becoming-health.D_vLN0tV_Z51bD1.webp&quot; alt=&quot;Aspire dashboard showing becoming healthy state&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Publishing and deploying&lt;/h2&gt;
&lt;p&gt;Aspire can optionally prepare the application for deployment by &apos;publishing&apos; parameterised assets. You can then &apos;deploy&apos; those assets to a specific target environment.&lt;/p&gt;
&lt;p&gt;Currently, the following deployment targets are supported:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Docker / Docker Compose&lt;/li&gt;
&lt;li&gt;Kubernetes&lt;/li&gt;
&lt;li&gt;Azure Container Apps&lt;/li&gt;
&lt;li&gt;Azure App Services&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Some service types may support these targets natively, while others may need additional configuration. There are also non-Azure targets available too. For example there&apos;s support for AWS with the &lt;a href=&quot;https://www.nuget.org/packages/Aspire.Hosting.AWS/&quot;&gt;Aspire.Hosting.AWS&lt;/a&gt; package. See &lt;a href=&quot;https://github.com/aws/integrations-on-dotnet-aspire-for-aws&quot;&gt;https://github.com/aws/integrations-on-dotnet-aspire-for-aws&lt;/a&gt; for more information.&lt;/p&gt;
&lt;p&gt;For example, adding this line to the AppHost above:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;builder.AddDockerComposeEnvironment(&quot;aspire-starter&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and then running the publish command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;aspire publish
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;causes a &lt;code&gt;docker-compose.yaml&lt;/code&gt; and &lt;code&gt;.env&lt;/code&gt; files to be created:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  aspire-starter-dashboard:
    image: &quot;mcr.microsoft.com/dotnet/nightly/aspire-dashboard:latest&quot;
    expose:
      - &quot;18888&quot;
      - &quot;18889&quot;
    networks:
      - &quot;aspire&quot;
    restart: &quot;always&quot;
  mongo:
    image: &quot;docker.io/library/mongo:8.0&quot;
    environment:
      MONGO_INITDB_ROOT_USERNAME: &quot;admin&quot;
      MONGO_INITDB_ROOT_PASSWORD: &quot;${MONGO_PASSWORD}&quot;
    expose:
      - &quot;27017&quot;
    volumes:
      - type: &quot;volume&quot;
        target: &quot;/data/db&quot;
        source: &quot;aspire-starter.apphost-06ac3d63aa-mongo-data&quot;
        read_only: false
    networks:
      - &quot;aspire&quot;
  apiservice:
    image: &quot;${APISERVICE_IMAGE}&quot;
    environment:
      OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: &quot;true&quot;
      OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: &quot;true&quot;
      OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: &quot;in_memory&quot;
      ASPNETCORE_FORWARDEDHEADERS_ENABLED: &quot;true&quot;
      HTTP_PORTS: &quot;${APISERVICE_PORT}&quot;
      ConnectionStrings__mongo: &quot;mongodb://admin:${MONGO_PASSWORD}@mongo:27017?authSource=admin&amp;amp;authMechanism=SCRAM-SHA-256&quot;
      OTEL_EXPORTER_OTLP_ENDPOINT: &quot;http://aspire-starter-dashboard:18889&quot;
      OTEL_EXPORTER_OTLP_PROTOCOL: &quot;grpc&quot;
      OTEL_SERVICE_NAME: &quot;apiservice&quot;
    expose:
      - &quot;${APISERVICE_PORT}&quot;
    depends_on:
      mongo:
        condition: &quot;service_started&quot;
    networks:
      - &quot;aspire&quot;
networks:
  aspire:
    driver: &quot;bridge&quot;
volumes:
  aspire-starter.apphost-06ac3d63aa-mongo-data:
    driver: &quot;local&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Just to repeat, the publish and deployment aspects of Aspire are entirely optional. If you&apos;ve already got Infrastructure as Code and/or CI/CD pipelines you can continue to use those, and just use Aspire for local development. But if you haven&apos;t got them in place, Aspire may be useful to help you get started.&lt;/p&gt;
&lt;h2&gt;Learning more and getting started&lt;/h2&gt;
&lt;p&gt;Head over to the new home of Aspire at &lt;a href=&quot;https://aspire.dev&quot;&gt;https://aspire.dev&lt;/a&gt; to find more details about how Aspire works and how to get started.&lt;/p&gt;
&lt;p&gt;(There is also the old documentation site learn.microsoft.com/dotnet/aspire/, but as I understand it the plan is for that content to be gradually migrated to the new site)&lt;/p&gt;
&lt;p&gt;Oh, and finally keep an eye out for my presentation happening as part of the &apos;Community&apos; day of &lt;a href=&quot;https://www.dotnetconf.net/agenda&quot;&gt;.NET Conf 2025&lt;/a&gt; where I&apos;ll be showing how well Aspire works with non-.NET applications!&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/aspire-logo-256.CA6LsmXl.png" width="256" height="256"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/aspire-logo-256.CA6LsmXl.png" width="256" height="256"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2025/10/dotnet-uninstall-tool</id>
    <updated>2025-10-17T11:30:00.000+10:30</updated>
    <title>Clean up old .NET installs with the .NET Uninstall Tool</title>
    <link href="https://david.gardiner.net.au/2025/10/dotnet-uninstall-tool" rel="alternate" type="text/html" title="Clean up old .NET installs with the .NET Uninstall Tool"/>
    <category term=".NET"/>
    <published>2025-10-17T11:30:00.000+10:30</published>
    <summary type="html">Using the .NET Uninstall Tool to remove redundant .NET SDK versions</summary>
    <content type="html">&lt;p&gt;New versions of .NET are released almost monthly with bug and security fixes. Visual Studio also will install specific versions of .NET SDKs. What this means is that over time you may end up with a lot of .NET SDKs and runtimes installed, some of which are redundant and just taking up disk space.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/dotnet.DQUHciW5_24pCaQ.webp&quot; alt=&quot;The .NET Logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You could uninstall specific versions manually, or you could use the &lt;a href=&quot;https://learn.microsoft.com/dotnet/core/additional-tools/uninstall-tool-overview?WT.mc_id=DOP-MVP-5001655&quot;&gt;.NET Uninstall Tool&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;On Windows if you&apos;re using Chocolatey, you can install the tool like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;choco install dotnet-uninstaller
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or you can download the installer directly from &lt;a href=&quot;https://github.com/dotnet/cli-lab/releases&quot;&gt;https://github.com/dotnet/cli-lab/releases&lt;/a&gt; (there&apos;s also a Mac OS version available). That GitHub repository has the source for the tool too.&lt;/p&gt;
&lt;p&gt;Here&apos;s what &lt;code&gt;dotnet --info&lt;/code&gt; shows on my machine:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.NET SDK:
 Version:           10.0.100-rc.2.25502.107
 Commit:            89c8f6a112
 Workload version:  10.0.100-manifests.4d32cd9e
 MSBuild version:   18.0.0-preview-25502-107+89c8f6a11

Runtime Environment:
 OS Name:     Windows
 OS Version:  10.0.26100
 OS Platform: Windows
 RID:         win-x64
 Base Path:   C:\Program Files\dotnet\sdk\10.0.100-rc.2.25502.107\

.NET workloads installed:
There are no installed workloads to display.
Configured to use workload sets when installing new manifests.
No workload sets are installed. Run &quot;dotnet workload restore&quot; to install a workload set.

Host:
  Version:      10.0.0-rc.2.25502.107
  Architecture: x64
  Commit:       89c8f6a112

.NET SDKs installed:
  3.1.426 [C:\Program Files\dotnet\sdk]
  6.0.428 [C:\Program Files\dotnet\sdk]
  8.0.318 [C:\Program Files\dotnet\sdk]
  8.0.415 [C:\Program Files\dotnet\sdk]
  9.0.205 [C:\Program Files\dotnet\sdk]
  9.0.305 [C:\Program Files\dotnet\sdk]
  9.0.306 [C:\Program Files\dotnet\sdk]
  10.0.100-rc.2.25502.107 [C:\Program Files\dotnet\sdk]

.NET runtimes installed:
  Microsoft.AspNetCore.App 3.1.32 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 6.0.36 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 8.0.21 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 9.0.6 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 9.0.9 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 9.0.10 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 10.0.0-rc.2.25502.107 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.NETCore.App 3.1.32 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.36 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 8.0.21 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 9.0.6 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 9.0.9 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 9.0.10 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 10.0.0-rc.2.25502.107 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.WindowsDesktop.App 3.1.32 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 6.0.36 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 8.0.21 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 9.0.6 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 9.0.9 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 9.0.10 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 10.0.0-rc.2.25502.107 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]

Other architectures found:
  x86   [C:\Program Files (x86)\dotnet]
    registered at [HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x86\InstallLocation]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I have Visual Studio 2022 and Visual Studio 2026 Insiders installed, and also used some of the Chocolatey packages to preinstall various SDK versions (and some runtimes may have been installed as dependencies of other software).&lt;/p&gt;
&lt;p&gt;Ideally I&apos;d just end up with the latest SDK for each major version. The Uninstall tool has a few different commands and combinations of options. First up let&apos;s see what the &lt;code&gt;list&lt;/code&gt; command generates:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotnet-core-uninstall list
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;This tool cannot uninstall versions of the runtime or SDK that are 
    - SDKs installed using Visual Studio 2019 Update 3 or later.
    - SDKs and runtimes installed via zip/scripts.
    - Runtimes installed with SDKs (these should be removed by removing that SDK).
The versions that can be uninstalled with this tool are:

.NET Core SDKs:
  9.0.306  x64    [Used by Visual Studio. Specify individually or use --force to remove]
  9.0.305  x64
  9.0.205  x64
  8.0.415  x64    [Used by Visual Studio 2022. Specify individually or use --force to remove]
  8.0.318  x64
  6.0.428  x64    [Used by Visual Studio 2022. Specify individually or use --force to remove]
  3.1.426  x64    [Used by Visual Studio 2019. Specify individually or use --force to remove]

.NET Core Runtimes:

ASP.NET Core Runtimes:

.NET Core Runtime &amp;amp; Hosting Bundles:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My goal is to remove 9.0.305, 9.0.205 and 8.0.318 SDKs, as I have newer versions of 8 and 9 already installed.&lt;/p&gt;
&lt;p&gt;Before committing to removing anything, it&apos;s probably a good idea to make use of the &lt;code&gt;whatif&lt;/code&gt; (aka &lt;code&gt;dry-run&lt;/code&gt;) command first, just so you can confirm it is going to do what you thought.&lt;/p&gt;
&lt;p&gt;First up, let&apos;s see what the &lt;code&gt;--all-lower-patches&lt;/code&gt; filter does:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotnet-core-uninstall whatif --all-lower-patches --sdk
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;*** DRY RUN OUTPUT
Specified versions:
  Microsoft .NET SDK 9.0.305 (x64)
*** END DRY RUN OUTPUT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Interesting - that&apos;s a good start, but it doesn&apos;t include the other two SDKs.&lt;/p&gt;
&lt;p&gt;What about &lt;code&gt;--all-but-latest&lt;/code&gt;?&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotnet-core-uninstall whatif --all-but-latest --sdk
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;*** DRY RUN OUTPUT
Specified versions:
  Microsoft .NET SDK 9.0.305 (x64)
  Microsoft .NET SDK 9.0.205 (x64)
  Microsoft .NET SDK 8.0.318 (x64)
*** END DRY RUN OUTPUT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That looks good! Let&apos;s do this..&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotnet-core-uninstall remove --all-but-latest --sdk --yes
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Uninstalling: Microsoft .NET SDK 9.0.305 (x64).
Uninstalling: Microsoft .NET SDK 9.0.205 (x64).
Uninstalling: Microsoft .NET SDK 8.0.318 (x64).
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And there you go!&lt;/p&gt;
&lt;p&gt;Here&apos;s what &lt;code&gt;dotnet --info&lt;/code&gt; produces now:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.NET SDK:
 Version:           10.0.100-rc.2.25502.107
 Commit:            89c8f6a112
 Workload version:  10.0.100-manifests.4d32cd9e
 MSBuild version:   18.0.0-preview-25502-107+89c8f6a11

Runtime Environment:
 OS Name:     Windows
 OS Version:  10.0.26100
 OS Platform: Windows
 RID:         win-x64
 Base Path:   C:\Program Files\dotnet\sdk\10.0.100-rc.2.25502.107\

.NET workloads installed:
There are no installed workloads to display.
Configured to use workload sets when installing new manifests.
No workload sets are installed. Run &quot;dotnet workload restore&quot; to install a workload set.

Host:
  Version:      10.0.0-rc.2.25502.107
  Architecture: x64
  Commit:       89c8f6a112

.NET SDKs installed:
  3.1.426 [C:\Program Files\dotnet\sdk]
  6.0.428 [C:\Program Files\dotnet\sdk]
  8.0.415 [C:\Program Files\dotnet\sdk]
  9.0.306 [C:\Program Files\dotnet\sdk]
  10.0.100-rc.2.25502.107 [C:\Program Files\dotnet\sdk]

.NET runtimes installed:
  Microsoft.AspNetCore.App 3.1.32 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 6.0.36 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 8.0.21 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 9.0.10 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 10.0.0-rc.2.25502.107 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.NETCore.App 3.1.32 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.36 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 8.0.21 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 9.0.9 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 9.0.10 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 10.0.0-rc.2.25502.107 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.WindowsDesktop.App 3.1.32 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 6.0.36 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 8.0.21 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 9.0.9 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 9.0.10 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 10.0.0-rc.2.25502.107 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That looks better. I can see there is still a 9.0.9 runtime that might be a candidate to be removed given there&apos;s also a 9.0.10. That specific runtime was originally installed as a Chocolatey package dependency, so I expect that will be resolved when the package that depends on that runtime version gets upgraded.&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/dotnet.DQUHciW5.png" width="456" height="456"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/dotnet.DQUHciW5.png" width="456" height="456"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2025/07/verify-cli</id>
    <updated>2025-07-28T07:00:00.000+09:30</updated>
    <title>Verify.Cli - Snapshot testing without tests</title>
    <link href="https://david.gardiner.net.au/2025/07/verify-cli" rel="alternate" type="text/html" title="Verify.Cli - Snapshot testing without tests"/>
    <category term=".NET"/>
    <category term="Unit Testing"/>
    <published>2025-07-28T07:00:00.000+09:30</published>
    <summary type="html">Verify.Cli is a new command-line tool for performing snapshot testing on individual files, without needing to create unit tests. Find out why I created this tool and examples of using it.</summary>
    <content type="html">&lt;p&gt;In &lt;a href=&quot;/2025/07/verify&quot;&gt;my last post&lt;/a&gt;, I looked at using the &lt;a href=&quot;https://github.com/VerifyTests/Verify&quot;&gt;Verify&lt;/a&gt; library to implement snapshot testing in .NET unit tests.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/verify-tests.CHaOWxZj_Zft2EW.webp&quot; alt=&quot;Verify logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I&apos;ve been doing quite a lot of &apos;DevOps&apos; work in recent years, and a large part of that has involved creating and optimising build and deployment (CI/CD) pipelines. Not infrequently I&apos;ve wanted to add a sanity check to a pipeline to ensure a file has the correct content. &quot;Oh if only I could call Verify from my pipeline&quot;, I&apos;d think.&lt;/p&gt;
&lt;p&gt;If the file was in version control, I could sometimes fallback to relying on &lt;code&gt;git diff&lt;/code&gt; to tell if the file had changed. But if it was a generated file that technique wouldn&apos;t work. Other file comparison tools (like regular &lt;code&gt;diff&lt;/code&gt;) might help, unless you had things like timestamps or other elements in the file that you wanted to ignore for the comparison. This is all bread and butter for Verify.&lt;/p&gt;
&lt;p&gt;And so I created &lt;a href=&quot;https://github.com/flcdrg/Verify.Cli&quot;&gt;Verify.Cli&lt;/a&gt;!&lt;/p&gt;
&lt;h2&gt;Verify.Cli&lt;/h2&gt;
&lt;p&gt;Verify.Cli is a command-line tool that uses Verify internally. But while Verify was originally written to be used in unit tests, it is possible to host it in a console application and get similar functionality. It can be used to perform snapshot testing on an individual file.&lt;/p&gt;
&lt;p&gt;You can install Verify.CLI as a .NET global tool, or there&apos;s a Docker image so you can run it from a container.&lt;/p&gt;
&lt;h2&gt;Case study - feed.xml&lt;/h2&gt;
&lt;p&gt;I&apos;ve recently rebuilt my blog using Astro. It generates a static website, but there is a challenge. Astro and the other libraries that are used to build my blog are updated frequently. It would be great to have confidence that those updates are not breaking the site. If it was a regular software application I could write some unit or integration tests. Given it generates static files, then the idea of snapshot testing some specific files would be helpful.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;feed.xml&lt;/code&gt; file is created when you run &lt;code&gt;pnpm build&lt;/code&gt; in the &lt;code&gt;dist&lt;/code&gt; folder. Here&apos;s part of it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&amp;gt;
&amp;lt;feed xmlns=&quot;http://www.w3.org/2005/Atom&quot; xml:lang=&quot;en-AU&quot; xmlns:media=&quot;http://search.yahoo.com/mrss/&quot;&amp;gt;
  &amp;lt;id&amp;gt;https://david.gardiner.net.au/feed.xml&amp;lt;/id&amp;gt;
  &amp;lt;title type=&quot;html&quot;&amp;gt;David Gardiner&amp;lt;/title&amp;gt;
  &amp;lt;updated&amp;gt;2025-07-26T10:39:59.564Z&amp;lt;/updated&amp;gt;
  &amp;lt;subtitle&amp;gt;A blog of software development, .NET and other interesting things&amp;lt;/subtitle&amp;gt;
  &amp;lt;generator uri=&quot;https://github.com/flcdrg/astrojs-atom&quot; version=&quot;1.0.48&quot;&amp;gt;astrojs-atom&amp;lt;/generator&amp;gt;
  &amp;lt;author&amp;gt;
    &amp;lt;name&amp;gt;David Gardiner&amp;lt;/name&amp;gt;
  &amp;lt;/author&amp;gt;
  &amp;lt;link href=&quot;https://david.gardiner.net.au/feed.xml&quot; rel=&quot;self&quot; type=&quot;application/atom+xml&quot;/&amp;gt;
  &amp;lt;link href=&quot;https://david.gardiner.net.au/&quot; rel=&quot;alternate&quot; type=&quot;text/html&quot; hreflang=&quot;en-AU&quot;/&amp;gt;
  &amp;lt;entry&amp;gt;
    &amp;lt;id&amp;gt;https://david.gardiner.net.au/2025/07/azure-pipeline-template-expression&amp;lt;/id&amp;gt;
    &amp;lt;updated&amp;gt;2025-07-14T08:00:00.000+09:30&amp;lt;/updated&amp;gt;
    &amp;lt;title&amp;gt;Azure Pipelines template expressions&amp;lt;/title&amp;gt;
    &amp;lt;link href=&quot;https://david.gardiner.net.au/2025/07/azure-pipeline-template-expression&quot; rel=&quot;alternate&quot; type=&quot;text/html&quot; title=&quot;Azure Pipelines template expressions&quot;/&amp;gt;
    &amp;lt;category term=&quot;Azure Pipelines&quot;/&amp;gt;
    &amp;lt;published&amp;gt;2025-07-14T08:00:00.000+09:30&amp;lt;/published&amp;gt;
    &amp;lt;summary type=&quot;html&quot;&amp;gt;
      &amp;lt;![CDATA[Template expressions are a compile-time feature of Azure Pipelines. Learn how they differ to custom conditions
and see some common examples of their usage.]]&amp;gt;
    &amp;lt;/summary&amp;gt;
    &amp;lt;content type=&quot;html&quot; xml:base=&quot;https://david.gardiner.net.au/2025/07/azure-pipeline-template-expression&quot;&amp;gt;
      &amp;lt;![CDATA[&amp;lt;p&amp;gt;In my &amp;lt;a href=&quot;/2025/06/azure-pipeline-conditionals&quot;&amp;gt;last post&amp;lt;/a&amp;gt; I wrote about using custom conditions in Azure Pipelines to evaluate whether to skip a step, job or stage at runtime.&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;Sometimes we can do better though. With template expressions we can not just skip something, we can remove it entirely. We can also use it to optionally insert values in a pipeline (something you can&apos;t do with runtime custom conditions).&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;The important thing to remember is that template expression are a &quot;compile time&quot; feature. They can only operate on things that are available at compile time. &amp;lt;a href=&quot;https://learn.microsoft.com/azure/devops/pipelines/process/set-variables-scripts?view=azure-devops&amp;amp;amp;WT.mc_id=DOP-MVP-5001655&quot;&amp;gt;Variables set by scripts&amp;lt;/a&amp;gt;, and &amp;lt;a href=&quot;https://learn.microsoft.com/azure/devops/pipelines/process/variables?view=azure-devops&amp;amp;amp;tabs=yaml%2Cbatch&amp;amp;amp;WT.mc_id=DOP-MVP-5001655#use-output-variables-from-tasks&quot;&amp;gt;task output variables&amp;lt;/a&amp;gt; are two examples of things that are not available at compile time.&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;Compare these two Azure Pipeline runs. The first uses custom conditions to decided if the &apos;Publish Artifact&apos; step is executed or not. Notice the &apos;Publish Artifact&apos; step is listed, but the icon shown is a white arrow (rather than green tick)
&amp;lt;img src=&quot;https://david.gardiner.net.au/_astro/azure-pipelines-custom-conditions.Be6IaBQz_101Uki.webp&quot; alt=&quot;Job showing a step &apos;Publish Artifact&apos; that was conditionally not executed&quot; /&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;If we use a template expression, then if it evaluates to false then the step is not even included in the job!&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;&amp;lt;img src=&quot;https://david.gardiner.net.au/_astro/azure-pipelines-template-expressions.kRV13og-_2f5chf.webp&quot; alt=&quot;Job without a &apos;Publish Artifact&apos; step &quot; /&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;&amp;lt;a href=&quot;https://learn.microsoft.com/azure/devops/pipelines/process/template-expressions?view=azure-devops&amp;amp;amp;WT.mc_id=DOP-MVP-5001655&quot;&amp;gt;Template expressions&amp;lt;/a&amp;gt; use the syntax &amp;lt;code&amp;gt;${{ }}&amp;lt;/code&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;You can reference &amp;lt;code&amp;gt;parameters&amp;lt;/code&amp;gt; and &amp;lt;code&amp;gt;variables&amp;lt;/code&amp;gt; in template expressions. The latter are only variables that are defined in the YAML file and most of the &amp;lt;a href=&quot;https://learn.microsoft.com/azure/devops/pipelines/build/variables?view=azure-devops&amp;amp;amp;WT.mc_id=DOP-MVP-5001655&quot;&amp;gt;predefined variables&amp;lt;/a&amp;gt;. (That page does list which variables can be used in template expressions, but you may need to scroll the page to the right to see that column!)&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;You can&apos;t reference variables that are created by scripts or anything else that is only available at runtime.&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;You can use &amp;lt;a href=&quot;https://learn.microsoft.com/azure/devops/pipelines/process/expressions?view=azure-devops&amp;amp;amp;WT.mc_id=DOP-MVP-5001655#functions&quot;&amp;gt;general functions&amp;lt;/a&amp;gt; (the same ones we used previously with runtime Custom Conditions) in template expressions, as well as two special &amp;lt;a href=&quot;https://learn.microsoft.com/azure/devops/pipelines/process/template-expressions?view=azure-devops&amp;amp;amp;WT.mc_id=DOP-MVP-5001655#template-expression-functions&quot;&amp;gt;Template expression functions&amp;lt;/a&amp;gt;.&amp;lt;/p&amp;gt;
&amp;lt;h2&amp;gt;Common patterns&amp;lt;/h2&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We run the Verify CLI against this file like this (after creating a &lt;code&gt;verified&lt;/code&gt; directory )&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;verify --file ./dist/feed.xml --verified-dir verified
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because there is no existing &lt;code&gt;verified/feed.xml.verified.xml&lt;/code&gt; file, the Verify library launches Beyond Compare (my diff tool of choice) and I can use it to create the verified file.&lt;/p&gt;
&lt;p&gt;However if I rebuild the site again and re-run verify, it shows up a difference. It turns out that the &lt;code&gt;&amp;lt;updated&amp;gt;2025-07-26T10:39:59.564Z&amp;lt;/updated&amp;gt;&lt;/code&gt; line is updated each time! Happily Verify&apos;s scrubbers can handle timestamps like that.&lt;/p&gt;
&lt;p&gt;If we were using Verify in a unit test, we&apos;d configure this scrubber using &lt;code&gt;VerifySettings.ScrubInlineDateTimes()&lt;/code&gt;. The equivalent for the command-line tool is &lt;code&gt;--scrub-inline-datetime&lt;/code&gt;. If we run verify again using this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;verify --file dist/feed.xml --verified-dir verified --scrub-inline-datetime &quot;yyyy-MM-ddTHH:mm:ss.fffZ&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We&apos;ll see that the scrubber has replaced the timestamp with a placeholder:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  &amp;lt;updated&amp;gt;DateTime_1&amp;lt;/updated&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With that in place we can repeat the validation with subsequent builds and be sure that the file should be equivalent. If the comparison ever fails in the future then we can be pretty confident it&apos;s caused by a change in our logic or an update to one of the libraries.&lt;/p&gt;
&lt;p&gt;Here&apos;s how I&apos;m using Verify.Cli in a GitHub Action workflow:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Setup .NET
  uses: actions/setup-dotnet@v5
  with:
    dotnet-version: 9.x

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

- name: Verify
  run: |
    verify --file dist/feed.xml --verified-dir verified --scrub-inline-datetime &quot;yyyy-MM-ddTHH:mm:ss.fffZ&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Case study - blog post&lt;/h2&gt;
&lt;p&gt;The other thing I was keen to keep an eye on was individual blog posts. Here&apos;s part of one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;
    &amp;lt;link rel=&quot;icon&quot; href=&quot;/favicon.ico&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&amp;gt;
    &amp;lt;meta name=&quot;generator&quot; content=&quot;Astro v5.12.0&quot;&amp;gt;
    &amp;lt;link rel=&quot;canonical&quot; href=&quot;https://david.gardiner.net.au/2025/07/azure-pipeline-template-expression&quot;&amp;gt;
    &amp;lt;meta name=&quot;description&quot; content=&quot;Template expressions are a compile-time feature of Azure Pipelines. Learn how they differ to custom conditions
and see some common examples of their usage.&quot;&amp;gt;&amp;lt;meta name=&quot;og:description&quot; content=&quot;Template expressions are a compile-time feature of Azure Pipelines. Learn how they differ to custom conditions
and see some common examples of their usage.&quot;&amp;gt;&amp;lt;meta name=&quot;og:title&quot; content=&quot;Azure Pipelines template expressions&quot;&amp;gt;&amp;lt;meta name=&quot;og:type&quot; content=&quot;article&quot;&amp;gt;&amp;lt;meta name=&quot;og:url&quot; content=&quot;https://david.gardiner.net.au/2025/07/azure-pipeline-template-expression&quot;&amp;gt;&amp;lt;meta name=&quot;og:image&quot; content=&quot;/_astro/azure-pipelines-logo.B45UakAg.png&quot;&amp;gt;&amp;lt;meta name=&quot;og:image:width&quot; content=&quot;80&quot;&amp;gt;&amp;lt;meta name=&quot;og:image:height&quot; content=&quot;80&quot;&amp;gt;&amp;lt;meta name=&quot;og:image:alt&quot; content=&quot;Azure Pipelines logo&quot;&amp;gt;&amp;lt;script type=&quot;application/ld+json&quot;&amp;gt;{&quot;@context&quot;:&quot;https://schema.org&quot;,&quot;@type&quot;:&quot;BlogPosting&quot;,&quot;mainEntityOfPage&quot;:{&quot;@type&quot;:&quot;WebPage&quot;,&quot;@id&quot;:&quot;https://david.gardiner.net.au/2025/07/azure-pipeline-template-expression&quot;},&quot;headline&quot;:&quot;Azure Pipelines template expressions&quot;,&quot;description&quot;:&quot;Template expressions are a compile-time feature of Azure Pipelines. Learn how they differ to custom conditions\nand see some common examples of their usage.&quot;,&quot;image&quot;:&quot;/_astro/azure-pipelines-logo.B45UakAg.png&quot;,&quot;author&quot;:{&quot;@type&quot;:&quot;Person&quot;,&quot;name&quot;:&quot;David Gardiner&quot;,&quot;url&quot;:&quot;https://david.gardiner.net.au/&quot;},&quot;datePublished&quot;:&quot;2025-07-14T08:00:00.000+09:30&quot;}&amp;lt;/script&amp;gt;
    &amp;lt;title&amp;gt;Azure Pipelines template expressions&amp;lt;/title&amp;gt;
    
  &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;/_astro/_slug_.CT3ttAlr.css&quot;&amp;gt;&amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;
    &amp;lt;header&amp;gt;
      &amp;lt;nav&amp;gt;
        &amp;lt;div class=&quot;nav-container&quot; data-astro-cid-pux6a34n&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most of that should be pretty stable. I tried a similar approach to what I used for &lt;code&gt;feed.xml&lt;/code&gt;, and created a pull request then ran it in the GitHub Actions workflow... and it failed!&lt;/p&gt;
&lt;p&gt;When I looked closer I realised that I&apos;ve configured pull request build to use &lt;code&gt;pnpm build --devOutput&lt;/code&gt;, whilst main branch builds just use &lt;code&gt;pnpm build&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;One difference with the former is that it adds an extra attribute to &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; elements - &lt;code&gt;data-image-component=&quot;true&quot;&lt;/code&gt;. To be able to compare files we&apos;ll need to remove all instances of that.&lt;/p&gt;
&lt;p&gt;Verify.Cli has the &lt;code&gt;--scrub-inline-remove&lt;/code&gt; parameter that we can use for that. eg.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--scrub-inline-remove &apos; data-image-component=&quot;true&quot;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When Astro processes images, it does appear to generate pretty stable URLs, but just in case it might be best to exclude those as well. Here&apos;s a typical &lt;code&gt;img&lt;/code&gt; tag:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;img src=&quot;/_astro/MVP_Logo_Horizontal_Preferred_Cyan300_RGB_300ppi.a-4xiaYx_2bYEEr.png&quot; 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are not fixed strings - they are unique for each image, so using a regular expression would make sense. This time we&apos;ll use &lt;code&gt;--scrub-inline-pattern&lt;/code&gt; with an appropriate expression:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--scrub-inline-pattern &apos;(?&amp;lt;prefix&amp;gt;&quot;)/_astro/[^&quot;]+(?&amp;lt;suffix&amp;gt;&quot;)&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This parameter has an extra feature that I&apos;m taking advantage of. If you include named groups in the regular expression &apos;prefix&apos; and or &apos;suffix&apos;, then the scrubber will add those back to the start and end. I&apos;m using that so that the double quotes are maintained.&lt;/p&gt;
&lt;p&gt;The replacement looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;img src=&quot;STRING_5&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here&apos;s the full command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;verify --file dist/2025/07/azure-pipeline-template-expression.html --verified-dir verified --scrub-inline-pattern &apos;(?&amp;lt;prefix&amp;gt;&quot;)/_astro/[^&quot;]+(?&amp;lt;suffix&amp;gt;&quot;)&apos; --scrub-inline-remove &apos; data-image-component=&quot;true&quot;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this case, the GitHub Action workflow is using the Docker image to run Verify.Cli (another way of using it instead of installing it as a .NET global tool):&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Verify post
  run: docker run --rm --user &quot;$(id -u):$(id -g)&quot; -v $PWD:/tmp flcdrg/verify-cli --file /tmp/dist/2025/07/azure-pipeline-template-expression.html --verified-dir /tmp/verified --scrub-inline-pattern &apos;(?&amp;lt;prefix&amp;gt;&quot;)/_astro/[^&quot;]+(?&amp;lt;suffix&amp;gt;&quot;)&apos; --scrub-inline-pattern &apos;(?&amp;lt;prefix&amp;gt;title=&quot;)[^&quot;]+(?&amp;lt;suffix&amp;gt;&quot;)&apos; --scrub-inline-remove &apos; data-image-component=&quot;true&quot;&apos; --scrub-inline-pattern &apos;(?&amp;lt;prefix&amp;gt;meta name=&quot;generator&quot; content=&quot;Astro v)[\d\.]+(?&amp;lt;suffix&amp;gt;&quot;)&apos; --verbosity detailed
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What if it fails?&lt;/h2&gt;
&lt;p&gt;If Verify.Cli fails locally on your machine, you can quickly identify the conflict via your visual diff tool of choice. But if it fails in a pipeline, then currently all you get is an error that the files are different. For now, I&apos;ve come up with a hacky workaround:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Diff files if error
  if: failure()
  run: |
    diff -u dist/feed.xml verified/feed.xml.verified.xml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This step in the workflow only runs if the Verify.Cli step failed. It assumes you have the &lt;code&gt;diff&lt;/code&gt; tool installed on your build agent. This could be an aspect to improve in Verify.Cli in the future.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Take a look at &lt;a href=&quot;https://github.com/flcdrg/astro-blog-engine&quot;&gt;https://github.com/flcdrg/astro-blog-engine&lt;/a&gt; for a repo making use of Verify.Cli (including the &lt;a href=&quot;https://github.com/flcdrg/astro-blog-engine/blob/main/.github/workflows/main.yml&quot;&gt;main workflow&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;If you have a need to use the snapshot testing technique on individual files outside of unit tests, please give Verify.Cli a go. Let me know in the comments what you think, and feel free to submit any suggestions or bug reports &lt;a href=&quot;https://github.com/flcdrg/Verify.Cli&quot;&gt;over at the repo&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Helmet by Leonidas Oikonomou from &lt;a href=&quot;https://thenounproject.com/browse/icons/term/helmet/&quot;&gt;Noun Project&lt;/a&gt; (CC BY 3.0)&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/verify-tests.CHaOWxZj.png" width="400" height="400"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/verify-tests.CHaOWxZj.png" width="400" height="400"/>
  </entry>
  <entry>
    <id>https://david.gardiner.net.au/2025/07/verify</id>
    <updated>2025-07-21T20:00:00.000+09:30</updated>
    <title>Snapshot testing .NET code with Verify</title>
    <link href="https://david.gardiner.net.au/2025/07/verify" rel="alternate" type="text/html" title="Snapshot testing .NET code with Verify"/>
    <category term=".NET"/>
    <category term="Unit Testing"/>
    <published>2025-07-21T20:00:00.000+09:30</published>
    <summary type="html">An introduction to using Verify for .NET snapshot testing</summary>
    <content type="html">&lt;p&gt;Snapshot testing has become a really popular way of asserting data has the correct shape and values in software unit tests. My first introduction to these kinds of tests was with &lt;a href=&quot;https://approvaltests.com/&quot;&gt;ApprovalTests&lt;/a&gt; for .NET. You may also have encountered them in JavaScript with &lt;a href=&quot;https://jestjs.io/docs/snapshot-testing&quot;&gt;Jest&lt;/a&gt;. But in my opinion the standout library for snapshot testing is Simon Cropp&apos;s excellent &lt;a href=&quot;https://github.com/VerifyTests/Verify&quot;&gt;Verify&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/verify-tests.CHaOWxZj_Zft2EW.webp&quot; alt=&quot;Verify logo&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I&apos;ve written previously about Verify, specifically an extension I created for Verify to facilitate &lt;a href=&quot;/2022/03/verify-mongodb&quot;&gt;snapshot testing with MongoDB&lt;/a&gt;. One thing that sets Verify apart is the ability to customise and configure how it works for each test, particularly in the way it serialises and sanitises/scrubs the data.&lt;/p&gt;
&lt;p&gt;Data scrubbing (see &lt;a href=&quot;https://github.com/VerifyTests/Verify/blob/main/docs/scrubbers.md&quot;&gt;Scrubbers&lt;/a&gt;) is how you deal with values that may change between unit test runs, but within a run they must be consistent. You can choose to either remove values entirely, or replace them with special placeholders.&lt;/p&gt;
&lt;p&gt;For example, consider the following C# code that creates a dynamic JSON object:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;var json = new
{
    id = Guid.NewGuid(),
    time = DateTimeOffset.Now,
    name = &quot;David Gardiner&quot;,
    currentUser = Environment.UserName
};

var text = System.Text.Json.JsonSerializer.Serialize(json);

var settings = new VerifySettings();
settings.ScrubUserName();

// Ensure received and verified files have .json suffix (otherwise it will be .txt)
settings.UseStrictJson();

return VerifyJson(text, settings);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The actual value of the JSON object will change every time the code is run, as it includes
values like a random GUID, the current date and time, and the current username. If you just compared a file that you&apos;d serialised this object to, then it wouldn&apos;t match if you compared it again a few seconds later.&lt;/p&gt;
&lt;p&gt;Instead, by the use of scrubbers and sanitisers, you end up with a file like this:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;{
  &quot;id&quot;: &quot;Guid_1&quot;,
  &quot;time&quot;: &quot;DateTimeOffset_1&quot;,
  &quot;name&quot;: &quot;David Gardiner&quot;,
  &quot;currentUser&quot;: &quot;TheUserName&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that the GUID, time and current user values have all been replaced with placeholders names. The clever thing is that if you had other properties with the same original values, then Verify is smart enough to swap those all over too (so you could easily confirm that &lt;code&gt;Guid_1&lt;/code&gt; was used in all the right places).&lt;/p&gt;
&lt;p&gt;This means that your test will continue to pass if you run it 5 seconds later, or if you run it on a build server on the other side of the world with a different process username.&lt;/p&gt;
&lt;p&gt;The underlying values will have changed, but the shape and usage of them hasn&apos;t, and that&apos;s what you care about.&lt;/p&gt;
&lt;p&gt;There&apos;s a default set of scrubbers and sanitisers that you get out of the box, but you can enable additional ones or write your own. In the example above I opted in to username scrubbing by calling &lt;code&gt;ScrubUserName()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If necessary, you can adjust how &lt;a href=&quot;https://github.com/VerifyTests/Verify/blob/main/docs/dates.md&quot;&gt;Dates&lt;/a&gt; and &lt;a href=&quot;https://github.com/VerifyTests/Verify/blob/main/docs/guids.md&quot;&gt;GUIDs&lt;/a&gt; are handled. There&apos;s even more options in &lt;a href=&quot;https://github.com/VerifyTests/Verify/blob/main/docs/serializer-settings.md&quot;&gt;Serializer Settings&lt;/a&gt; and &lt;a href=&quot;https://github.com/VerifyTests/Verify/blob/main/docs/scrubbers.md&quot;&gt;Scrubbers&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Snapshot tests dramatically simplify your unit tests - the alternative would be lots of specific property asserts, which can get pretty messy.&lt;/p&gt;
&lt;p&gt;The other aspect of Verify that really shines is how it can integrate with &lt;a href=&quot;https://github.com/VerifyTests/DiffEngine#supported-tools&quot;&gt;any of a number of popular GUI diff tools&lt;/a&gt; when you&apos;re running it interactively and a comparison fails.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://david.gardiner.net.au/_astro/beyondcompare.POaTT2nm_Z2b50cM.webp&quot; alt=&quot;Screenshot of Beyond Compare showing the &apos;name&apos; line different because the received data includes a middle initial that isn&apos;t present in the verified data&quot; /&gt;&lt;/p&gt;
&lt;p&gt;In that scenario, it will automatically launch your diff tool (&lt;a href=&quot;https://www.scootersoftware.com/&quot;&gt;Beyond Compare&lt;/a&gt; is my diff tool of choice) showing you the actual and expected (verified) text and you can easily identify if the failure represents a bug that needs to be fixed, or alternatively it might be an expected change so you can easily update the verified content.&lt;/p&gt;
&lt;p&gt;It&apos;s worth noting that Verify is smart enough to realise when it is running non-interactively (like on a build server), and in that case it just reports the comparison failed, causing the related unit test to fail. No GUI diff tools are launched.&lt;/p&gt;
&lt;p&gt;In my next post I&apos;ll show how I took Verify and found a way to use it outside of unit tests.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Side note: The code samples in this blog post were included by using one of Simon&apos;s other projects - &lt;a href=&quot;https://github.com/SimonCropp/MarkdownSnippets&quot;&gt;https://github.com/SimonCropp/MarkdownSnippets&lt;/a&gt;. A really clever tool for embedding code samples in Markdown files, which has the advantage that the code samples are more likely to be valid code as you can compile the original source files&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Helmet by Leonidas Oikonomou from &lt;a href=&quot;https://thenounproject.com/browse/icons/term/helmet/&quot;&gt;Noun Project&lt;/a&gt; (CC BY 3.0)&lt;/p&gt;
</content>
    <media:thumbnail url="https://david.gardiner.net.au/_astro/verify-tests.CHaOWxZj.png" width="400" height="400"/>
    <media:content medium="image" url="https://david.gardiner.net.au/_astro/verify-tests.CHaOWxZj.png" width="400" height="400"/>
  </entry>
</feed>
