Announcing .NET 8 Preview 3

Microsoft shipped .NET 8 Preview 3 in April 2023, and this build brings a set of changes that matter more for day to day development workflow than for flashy new APIs. The headline items are a simplified build output layout, a new command to clean up orphaned SDK workloads, a source generator for configuration binding, and improvements to how you build multi-platform container images. There are also JIT optimizations for Arm64 and continued work on dynamic profile guided optimization (PGO), though those land quietly in the runtime rather than through anything you write in your own code.

If you track .NET previews to plan an upgrade, this release is worth a closer look because several of these changes affect build scripts, CI pipelines, and Dockerfiles rather than application code. That is exactly the kind of thing that breaks silently if you only skim the release notes.

A simplified, unified build output path

Anyone who has worked on a reasonably sized .NET solution knows the pain of hunting through nested bin and obj folders across dozens of projects, each with its own Debug, Release, and target framework subfolder. The layout is deeply project-relative, which makes it hard for build tools and scripts to reliably locate every output artifact, and it can shift unexpectedly when someone tweaks an MSBuild property. .NET 8 Preview 3 introduces an opt-in setting that collapses this into a single, predictable structure at the repository root.

You turn it on by setting the UseArtifactsOutput property, ideally in a Directory.Build.props file so it applies to every project in the repo. The SDK even gives you a template to scaffold this file if you do not already have one.

dotnet new buildprops
 
<!-- Add this inside the generated Directory.Build.props -->
<PropertyGroup>
  <UseArtifactsOutput>true</UseArtifactsOutput>
</PropertyGroup>

Once this is set, every project in the solution writes its build output under a single .artifacts folder at the repo root instead of scattering bin and obj folders under each project directory. You can redirect this to a different folder by setting ArtifactsPath if .artifacts does not suit your naming conventions or clashes with an existing folder.

The new layout follows the pattern ArtifactsPath, then the type of output, then the project name, then a set of pivots that capture configuration and runtime identifier. In practice that gives you paths like .artifacts\bin\debug for a simple project build, .artifacts\bin\MyApp\debug_net8.0 for a multi targeted project, and .artifacts\publish\MyApp\release_linux-x64 when you publish for a specific runtime. NuGet packages land under .artifacts\package\release.

For teams maintaining CI pipelines, this is genuinely useful. You stop writing brittle glob patterns that try to catch every project’s bin folder, and instead point your packaging or artifact upload step at one location. The trade off is that it is opt-in and repo-wide, so mixing old-style and new-style output across projects in the same solution is not something you want to attempt. If you adopt this on an existing large repo, budget time to update any scripts, Dockerfiles, or CI YAML that hardcode the old bin/obj paths, because they will silently stop finding your DLLs otherwise.

Cleaning up orphaned SDK workloads

If you have worked with .NET MAUI, Blazor WebAssembly tooling, or any other workload-based SDK component across several SDK feature band upgrades, you have probably noticed workload packs that never seem to get removed even after uninstalling the SDK version that installed them. Some developers resorted to manually deleting workload folders from the SDK install directory, which the SDK team explicitly does not recommend since it can leave installation records in an inconsistent state.

Preview 3 adds a proper command for this instead.

dotnet workload clean

Running this without any flags performs standard garbage collection for the current SDK feature band. It removes orphaned packs left behind by uninstalled SDK versions or packs whose installation records no longer exist, but only for the given SDK version or earlier. If you also have a newer SDK installed, you need to run the command again from that version to clean its own leftovers. If Visual Studio has been managing workloads on the same machine, the command lists those Visual Studio-owned workloads separately and tells you to uninstall them through Visual Studio instead, since the CLI will not touch them.

There is also a more aggressive mode.

dotnet workload clean --all

This variant is not limited to the current or older SDK versions. It clears every pack on the machine that is not owned by Visual Studio and matches the current SDK’s installation type, whether that is file-based or MSI-based, and it also removes the associated workload installation records for the running feature band and below. Reach for the plain clean command first when you are troubleshooting a specific workload issue, and only use –all when you genuinely want to reset workload state across the board, since it is not reversible without reinstalling everything.

ValidateOptionsResultBuilder for cleaner options validation

If you have implemented IValidateOptions<TOptions> before, you know the awkwardness of trying to report multiple validation failures at once instead of bailing out on the first one. ValidateOptionsResultBuilder addresses this directly by letting you accumulate several errors before producing the final ValidateOptionsResult.

ValidateOptionsResultBuilder builder = new();
builder.AddError("Error: invalid operation code");
builder.AddResult(ValidateOptionsResult.Fail("Invalid request parameters"));
builder.AddError("Malformed link", "Url");
 
// Build ValidateOptionsResult object, accumulating multiple errors.
ValidateOptionsResult result = builder.Build();
 
// Reset the builder so it can be reused for a new validation pass.
builder.Clear();

The builder gives you AddError for a single message, AddResult to merge in another ValidateOptionsResult (handy when you are composing validation across multiple sub-validators), and Build to produce the final combined result. Calling Clear resets internal state so you can reuse the same builder instance for the next options type instead of allocating a new one every time. This matters more than it looks in configuration-heavy applications, where surfacing every misconfigured setting in one error response saves a user several rounds of fix-one-error-at-a-time debugging on startup.

Source generator for configuration binding

ASP.NET Core configuration binding has always leaned on ConfigurationBinder, which uses reflection to map IConfiguration key-value pairs onto strongly typed option classes through its Bind and Get methods. Reflection based binding works fine for most apps, but it is a real problem if you care about trimming or Native AOT, since the trimmer cannot always tell which types and members the reflection code will touch at runtime and ends up either keeping too much or breaking things.

In .NET 8, Microsoft is introducing a source generator that produces the equivalent binding code at compile time, with no reflection involved. The generator scans your code for Configure, Bind, and Get calls and generates strongly typed binding logic for the types it finds.

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
 
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
IConfigurationSection section = builder.Configuration.GetSection("MyOptions");
 
// Configure call - replaced with a source-generated implementation
builder.Services.Configure<MyOptions>(section);
 
// Get call - replaced with a source-generated implementation
MyOptions options0 = section.Get<MyOptions>();
 
// Bind call - replaced with a source-generated implementation
MyOptions options1 = new MyOptions();
section.Bind(options1);
 
WebApplication app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
 
public class MyOptions
{
    public int A { get; set; }
    public string S { get; set; }
    public byte[] Data { get; set; }
    public Dictionary<string, string> Values { get; set; }
    public List<MyClass> Values2 { get; set; }
}
 
public class MyClass
{
    public int SomethingElse { get; set; }
}

Nothing changes in how you call Configure, Bind, or Get. Once the generator is enabled, the compiler picks the generated implementations over the reflection-based framework code automatically, so this is a drop-in performance and AOT-compatibility improvement rather than an API you need to learn. To turn it on in Preview 3, you need the latest preview of the Microsoft.Extensions.Configuration.Binder package and this project property, since the feature is off by default.

<PropertyGroup>
  <EnableMicrosoftExtensionsConfigurationBinderSourceGenerator>true</EnableMicrosoftExtensionsConfigurationBinderSourceGenerator>
</PropertyGroup>

Microsoft mentioned that Preview 4 would move the enabling mechanism into the SDK itself, removing the need for the explicit package reference. If you are building anything you plan to publish as Native AOT or trim aggressively, keep an eye on this feature because reflection-heavy configuration binding is one of the more common things that silently breaks under trimming.

JIT and native code generation improvements

This release carries forward a batch of JIT compiler optimizations that you do not call directly but benefit from automatically once you upgrade. On Arm64, the JIT now converts certain OR combinations of relational comparisons into a single CCMP instruction, and it improves code generation for common comparisons against zero such as x < 0 and x >= 0, along with better overflow checks for division in specific scenarios. None of this requires code changes on your part, but if you run Arm64 workloads, for example on Apple Silicon dev machines or Arm-based cloud instances, these are the kind of incremental wins that add up across a large codebase.

Dynamic PGO also gets continued investment in this preview, including interlocked profiling for basic block counts and expanded profile synthesis support. Combined with general JIT throughput improvements, like folding unreachable switch cases earlier and adding System.Runtime.CompilerServices.Unsafe.BitCast for efficient type reinterpretation, this is groundwork more than a single headline feature. Worth tracking across the full preview cycle if raw throughput matters for your workload, but not something to chase preview by preview in production.

Building multi-platform container images

With Arm64 dev machines and Arm-based cloud nodes now common alongside x64, cross-architecture container builds come up more often than they used to. If you are on an Apple Silicon Mac and need to target an x64 service in Azure, the straightforward approach is to force the target platform explicitly.

docker build --pull -t app --platform linux/amd64 .

That works, but it means your build runs under emulation if your local machine is not x64, since .NET does not support QEMU. Preview 3 introduces a pattern that lets the build stage run on your local machine’s native architecture while still producing output for the target architecture. You change one line in your Dockerfile’s build stage.

FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-preview-alpine AS build

This is a Docker feature, not something new in .NET itself, but the SDK in Preview 3 was updated to properly support $TARGETARCH values and a new -a argument on dotnet restore, which lets you restore and publish for the target architecture even while the build stage itself runs natively.

RUN dotnet restore -a $TARGETARCH
 
# copy everything else and build app
COPY aspnetapp/. .
RUN dotnet publish -a $TARGETARCH --self-contained false --no-restore -o /app

The practical benefit is build speed. Native builds are noticeably faster than emulated ones, and this pattern lets you keep that speed while still producing images for a different target architecture, which fits naturally with Docker’s –platform based multi-platform image workflows. If your team builds container images on Apple Silicon laptops and deploys to x64 Kubernetes clusters, or the reverse, this is worth adopting once you are on .NET 8.

A consistent way to reference the non-root user

.NET container images added a non-root user starting in Preview 1, mainly to satisfy Kubernetes security policies that require containers to run without root privileges. One wrinkle was that Kubernetes’ runAsNonRoot check validates the user by UID, not by username, and Microsoft did not want every team hardcoding the numeric UID across thousands of Dockerfiles. Preview 3 fixes this by exposing the UID through an environment variable instead.

USER $APP_UID

This is the recommended pattern going forward for .NET 8 container images. When you inspect a container built this way, the configured user shows up as the numeric UID rather than a name.

$ docker inspect app | jq .[-1].Config.User
"64198"
 
$ docker run --rm -it mcr.microsoft.com/dotnet/runtime bash -c "export | grep APP"
declare -x APP_UID="64198"

Using the environment variable instead of a hardcoded number means the value stays consistent even if Microsoft changes the underlying UID in a future image update, and it keeps your Dockerfile readable instead of having a magic number that nobody remembers the reason for.

What this means if you are planning a .NET 8 upgrade

None of the changes in Preview 3 are breaking in a way that affects application logic, but several of them touch build infrastructure, and that is where upgrades quietly go wrong. If you plan to adopt the simplified artifacts output path, treat it as a repo-wide decision and audit CI scripts, Dockerfiles, and any tooling that assumes the classic bin/obj layout before you flip the switch. The workload clean command is safe to use immediately once you are on a Preview 3 or later SDK, and it is a good habit to run periodically on dev machines that have hopped across several SDK versions.

The configuration binding source generator and the container build changes are both worth prioritizing if Native AOT, trimming, or multi-architecture deployments are already on your roadmap, since they solve real friction in those specific scenarios. If none of that applies to your project yet, they are safe to defer until you actually need them, since none of this is mandatory to move to .NET 8 when it reaches general availability.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading