What's New in .NET — Day 1

.NET 10 & C# 14 LTS

Everything today is generally available, supported until November 2028, and safe to put into production. The only real question is sequencing.

What you'll leave with
  • A concrete upgrade order for your own codebase
  • Working code for every feature that matters to you
  • An API and a data model that you migrate to .NET 11 tomorrow
Deliberately scoped

Weighted towards EF Core, ASP.NET Core and the .NET libraries. No MAUI, no Blazor, no F#/VB.NET.

Open by asking the room which .NET version they're on today. The answer changes how you pitch the rest of the day: a room still on .NET 8 or .NET 9 has roughly eight weeks of support left and needs an urgent plan, not a feature tour.

Also ask who owns the upgrade decision. If nobody in the room does, shift emphasis towards evidence they can take back to whoever does.

How today runs

Five modules. The middle three carry the most change for server-side teams, so they get the time.

ModuleWhy it's here
1 · Platform, runtime & toolingThe upgrade decision, and what the runtime does differently underneath you
2 · C# 14Extension members, and the changes that alter behaviour silently
3 · LibrariesPost-quantum cryptography, and System.Text.Json changes that alter existing behaviour
4 · ASP.NET CoreOpenAPI 3.1 by default, and what it does to your generated clients
5 · EF CoreComplex types, query translation changes, and one migration that will surprise you
Deliberately not covered

MAUI, WPF, Windows Forms, Blazor, F# and VB.NET. This workshop targets server-side and data-access code, so the time goes elsewhere. Say so early if that is wrong for the room.

Set expectations about the labs now: they are optional in the sense that the content stands without them, but the C# 14 and EF Core ones are where the sharp edges become real.

Why this deck measures things

Release notes describe intent. They are written before the code ships, and they are not always right about what you'll actually observe.

While preparing this workshop, a number of widely repeated claims failed when compiled and run — including claims from documentation, from conference talks, and from an earlier version of these slides. So every risky statement today was checked on a real machine.

What this treatment means

Where you see this box, the claim was compiled and run on the presenter's machine, and it corrects something that a document, a blog post or an earlier draft of this deck got wrong. The code is in the workshop repository, so you can re-run it.

What it means for you

Not "distrust the docs". Rather: for anything that changes behaviour silently — overload resolution, serialisation defaults, query translation — a five-minute experiment beats an afternoon of reading. Several of today's sharpest findings took exactly that long to produce.

This slide earns the room's attention for the rest of the day, so don't skip it. If you want a hook, jump forward to the overload-resolution finding in module 2 or the stack allocation measurement later in this module and show the numbers.

FACILITATOR.md section 3 is the full list of corrections with the code that produced them. Keep it open while presenting.

The cadence, and why it decides your upgrade plan

.NET ships every November, alternating between long-term and standard-term support. That alternation — not the feature list — is what drives most upgrade decisions.

ReleaseShippedTrackSupport endsWhere you stand
.NET 8Nov 2023LTS10 Nov 2026Maintenance — weeks left
.NET 9Nov 2024STS10 Nov 2026Maintenance — weeks left
.NET 10Nov 2025LTS14 Nov 2028Today's target
.NET 11Nov 2026 (expected)STS~Nov 2028Tomorrow's decision
.NET 12Nov 2027 (expected)LTS—No previews yet
STS is 24 months now, not 18 — and it changes the arithmetic

Microsoft extended STS support from 18 to 24 months, effective from .NET 9. STS releases now get 12 months past their successor, the same as LTS.

Two consequences most people have not caught up with:

  • .NET 9 did not expire in May 2026. It runs to 10 November 2026 — the very same day as .NET 8. If you assumed you were already unsupported, you are not.
  • .NET 8 and .NET 9 now die together, in about eight weeks. Whichever of the two you are on, the deadline is identical and it is close.
The practical reading

If you are on .NET 8 or .NET 9, you have until 10 November 2026, and .NET 10 is the obvious destination — it is GA, LTS and supported for three more years.

The interesting part is what this does to tomorrow's decision. .NET 10 LTS ends 14 November 2028. .NET 11 STS, shipping November 2026 with 24 months, ends around November 2028. Those are the same week. The old reflex of "take LTS because it is supported longer" no longer describes reality.

Worth being explicit: STS releases are not "beta" or lower quality. They carry the same production-support commitment. Since the 24-month change they carry very nearly the same window too.

Expect pushback here — plenty of people in the room will "know" that STS is 18 months, because it was until the .NET 9 announcement. The sources page links the announcement; it is worth showing if challenged. This correction sets up the Day 2 decision slide, so do not rush it.

What has to move together

"Upgrade to .NET 10" is not one decision. These versions are coupled, and getting one wrong produces confusing failures later.

ThingCoupled toIf you get it wrong
TargetFrameworknet10.0 requires the .NET 10 runtime on every machine that runs itStartup failure with a framework-not-found message
C# language versionDefaults to C# 14 for net10.0You get new language behaviour whether or not you asked for it
SDKBuilding net10.0 needs the .NET 10 SDK, including on CICI breaks after developers' machines are already fine
EF CoreEF Core 10 requires .NET 10 and will not run on .NET FrameworkSilent loss of this release's query fixes
ASP.NET CoreShips with the runtime, not as an independent package versionSurprise behaviour changes arriving with the retarget
The language version is the one people forget

Retargeting to net10.0 moves you to C# 14 by default. Module 2 shows two changes that alter what compiling code does, without an error and in one case without even a warning. If you want to separate those risks, retarget with <LangVersion>13.0</LangVersion> first, then drop it in a second commit.

The LangVersion-pinning trick is the single most useful piece of advice in this module for a large codebase. It turns one scary upgrade into two boring ones. Say it twice.

Getting to .NET 10

Retargeting is usually a one-line change. The work is in what surfaces afterwards.

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <!-- C# 14 is the default for net10.0; you do not need to set LangVersion. -->
</PropertyGroup>

A pragmatic order that keeps each step independently revertible:

  1. Retarget and build. Fix only what breaks compilation.
  2. Run your test suite before adopting a single new language feature. Keep the "did the runtime change break us?" question separate from "did our refactor break us?"
  3. Update packages — especially EF Core, which must match the major version.
  4. Then adopt C# 14 features, where they earn their place.
EF Core versions are not optional

EF Core 10 requires .NET 10. Mixing an EF Core 9 package into a net10.0 app usually works but leaves you without this release's query-translation fixes — and it silently blocks tomorrow's EF Core 11 migration lab. Upgrade the package with the TFM.

Tooling that helps

  • GitHub Copilot upgrade — the agent that replaced Upgrade Assistant. Assesses the solution, proposes a plan, applies fixes and commits each step so you can review or roll back. Available in Visual Studio, VS Code, the Copilot CLI and GitHub.com.
  • dotnet list package --outdated — the fastest way to find package drift.
  • dotnet list package --vulnerable --include-transitive — run this anyway; upgrades are a good moment to clear known CVEs.
Do not reach for dotnet-upgrade-assistant

It is officially deprecated. Learn now carries the notice on the tool's own overview page and points to the Copilot upgrade agent instead. This matters in the room because the tool has a decade of muscle memory and near-universal blog coverage behind it — someone will suggest it, and it is still installable, so nothing tells you it has been retired.

Worth noting for planning: the deprecation notice describes the replacement as a Visual Studio feature, but the agent's own documentation is newer and broader — VS, VS Code, Copilot CLI and GitHub.com. If your team is not on Visual Studio, you are not excluded.

If anyone asks about .NET Framework, be direct: this path does not apply. That is a rewrite conversation, not an upgrade conversation, and it is out of scope today.

The Upgrade Assistant deprecation is a good small demonstration of why this deck cites retrieval dates. The tool was the standard answer for years; the notice went up in 2026 and nothing in the tooling itself signals it. Expect at least one person to push back.

Triaging the breaking changes

Microsoft publishes a per-release breaking-change list. It is long, and most of it will not apply to you. Triage it by how the break reaches you rather than reading top to bottom.

Source

Your code stops compiling. The best kind: loud, immediate, and the compiler tells you where. Budget an afternoon.

Binary

Compiles, fails to load or bind at runtime. Usually a dependency built against an older surface. Caught by running the app at all.

Behavioural

Compiles, runs, does something different. The expensive kind, and the reason the rest of today keeps stopping to measure.

Where today's behavioural breaks live

Four, and each gets its own slide later: overload resolution moving to spans (module 2), the field keyword shadowing an existing member (module 2), OpenAPI documents changing shape (module 4), and a query that used to run on the server now changing its parameterisation (module 5).

Ask how the room currently finds out about behavioural changes. The common honest answer is "a customer tells us", which is the argument for the deliberate, boring upgrade order on the previous slide.

File-based apps: C# without a project

.NET 10 lets you run a single .cs file directly. No .csproj, no obj/, no scaffolding.

dotnet run hello.cs

Dependencies and settings come from #: directives at the top of the file:

#!/usr/bin/env dotnet
#:package Humanizer@2.14.1

using Humanizer;

var when = DateTime.UtcNow.AddHours(-30);
Console.WriteLine($"no csproj, one file: {when.Humanize()}");

Run it with no project file present at all:

no csproj, one file: yesterday
Why this matters beyond demos

It removes the friction from the things people currently do badly: build scripts, one-off data fixes, reproducible bug reports. A colleague can now send you a single file that actually runs — and the #:package directive means it can use NuGet without any ceremony.

Good live demo: write a five-line file in front of the room and run it. Then add a #:package directive and run it again to show restore happening transparently. It lands better than any slide about it.

The shebang line matters for Linux and macOS attendees: with it, a .cs file becomes a directly executable script.

When a script outgrows itself

dotnet project convert promotes a file-based app to a normal project. What it actually produces is worth seeing before you rely on it.

dotnet project convert app.cs
app.cs
app\app.cs
app\app.csproj
Three surprises, all measured
  • It creates a subdirectory and does not convert in place. Your original app.cs is left behind, so you end up holding both the script and the project.
  • The generated project turns on <PublishAot>true</PublishAot> and <PackAsTool>true</PackAsTool> — neither is what dotnet new console gives you.
  • It injects a UserSecretsId you did not ask for.
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net10.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
  <Nullable>enable</Nullable>
  <PublishAot>true</PublishAot>
  <PackAsTool>true</PackAsTool>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="Humanizer" Version="2.14.1" />
</ItemGroup>
Read the generated file before committing it

PublishAot changes what compiles: reflection-heavy code and some serialisation paths will start producing trim and AOT warnings that were not there before. That is a reasonable default for a small tool and a poor one for a service. Delete the properties you did not want.

This is a good moment to reinforce the theme: the feature works exactly as advertised, but nobody tells you about the defaults until you look. Running the command live takes fifteen seconds and the room remembers it.

The CLI grew some useful verbs

The command line is drifting towards a noun-first shape, and picked up a couple of things worth putting in your muscle memory.

CommandWhat it does
dotnet package addAdds a NuGet package reference. The noun-first sibling of dotnet add package.
dotnet tool execRuns a tool without permanently installing it.
dnxThe short form of the same thing — .NET's answer to npx.
dotnet --cli-schemaEmits the entire CLI surface as JSON.
Confirmed on this machine

All four exist on SDK 10.0.300. dotnet tool exec --help describes itself as "Executes a tool from source without permanently installing it", dotnet --cli-schema exits successfully with roughly 335,000 characters of JSON, and dnx ships as a real executable next to dotnet (C:\Program Files\dotnet\dnx.cmd).

Running a tool through it leaves nothing behind — this is the whole point:

dnx dotnetsay "Hello from dnx" --yes     # the tool runs

dotnet tool list --global
Package Id      Version      Commands     # still empty
The one that changes a habit

dnx removes the reason to globally install one-shot tools onto build agents. Anything you currently tool install in CI purely to run once is a candidate, and it stops tool versions leaking between jobs.

Microsoft Learn has already switched its own guidance: the install instructions for dotnet-counters now list dnx dotnet-counters as the recommended option, ahead of installing it as a global tool.

--cli-schema sounds like trivia but it is how you build reliable tab completion and how an agent or script discovers options without scraping help text. Mention it if the room has platform engineers in it.

dnx is the name people will remember, so lead with it. dotnet tool exec is the same feature spelled out; mention it once so nobody is confused when they meet it in docs. If the room writes CI pipelines, this is the most immediately useful thing in the module — ask how many tools they install on agents just to invoke once.

The runtime change you can actually see

.NET 10's JIT can prove that a small array never leaves its method, and then not allocate it on the heap at all.

The test method builds a local int[4], fills it, sums it, and returns an int. The array never escapes. A sibling method returns the array instead, so it must escape. Both were called two million times, measuring GC.GetAllocatedBytesForCurrentThread(), on three runtimes with the same source.

runtime 8.0.31
  local array  : 40.00 bytes/call
  escaping     : 40.00 bytes/call

runtime 9.0.20
  local array  : 40.00 bytes/call
  escaping     : 40.00 bytes/call

runtime 10.0.12
  local array  : 0.00 bytes/call
  escaping     : 40.00 bytes/call
Two things are true at once
  • .NET 10 removed the allocation completely — 0 bytes per call where .NET 8 and .NET 9 both allocated 40.
  • The allocation comes straight back the moment the array escapes. This is real escape analysis, not a blanket change, and it cannot help an array you return, store in a field, or hand to another object.

40 bytes is the object header plus length plus four ints, rounded up. If someone asks why it isn't 16, that's the answer.

Resist the urge to let the room conclude "so allocation is free now". The very next slide exists to stop that conclusion.

The catch, and why it's the more useful finding

The same binary, run three times in a row on the same machine, with nothing changed:

--- run 1 ---
  local array : 0.00 bytes/call
--- run 2 ---
  local array : 40.00 bytes/call
--- run 3 ---
  local array : 0.00 bytes/call
It is not deterministic

Whether the allocation disappears depends on tiered compilation having promoted that method to fully optimised code before the hot loop runs. A warm method gets the optimisation. A cold one does not. Nothing in your source decides it.

What to take away
  • This is a JIT heuristic, not a language guarantee. Don't design data structures around it and don't assert on it in a test.
  • It is genuine free money on hot paths, which is exactly where tiering will have warmed the method up anyway.
  • If it matters to your workload, measure your workload. A table like the previous slide's is evidence, not a promise.

This is the most honest slide in the module and usually the most appreciated. It also pre-empts the person who goes home, writes a micro-benchmark, sees 40 bytes and concludes the workshop was wrong.

Measuring this yourself, cheaply

Every measurement in today's deck came from one of three tools. None of them needs a profiler licence or a spare afternoon.

Allocation, exactly

GC.GetAllocatedBytesForCurrentThread() around a loop. Exact, cheap, and it answers "did this allocate?" with a number rather than an opinion.

Throughput, properly

BenchmarkDotNet, when you need timings rather than allocations. It handles warm-up and tiering for you, which as we just saw is not a detail.

A running process

dotnet-counters against a live application, when the question is about production rather than a micro-benchmark.

GC.Collect();
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 2_000_000; i++) acc += Work(i);
long after = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"{(after - before) / 2_000_000.0:F2} bytes/call");
Warm up, or measure the wrong thing

Without a warm-up loop you measure tier-0 code, which is not the code that runs in production. The multi-runtime comparison above only became stable after several hundred thousand warm-up calls — and even then, see the previous slide.

Offer this as the pattern to take home: a ten-line harness, run against two runtimes, is enough to settle most "is this actually faster?" arguments that a team has.

Demo: the full harness ships with this workshop under verify/EscapeAnalysis. Run ./run.ps1 -Repeat 3 on the projector to reproduce the whole table live, including the non-determinism. It is more convincing than the slide and it re-checks the claim against whatever SDK is on the machine.

C# 14: not just nicer syntax C# 14

C# 14 ships with .NET 10. The headline is extension members; the upgrade risk is overload resolution around spans.

FeatureWhat it replacesUpgrade posture
Extension membersHelper classes with only extension methodsAdopt deliberately; design surface area changes
field keywordManual backing fields for validation and normalisationGood refactor, but audit names first
First-class spansSome explicit .AsSpan() calls and overload duplicationAudit before retargeting language version
Null-conditional assignmentif (x is not null) x.P = y;Useful, with clear short-circuit semantics
Smaller featuresString literals, verbose lambdas, generator gapsAdopt when local readability improves
Separate the runtime upgrade from the language upgrade

Retargeting to net10.0 defaults you to C# 14. For large systems, consider one commit that moves the TFM while pinning <LangVersion>13.0</LangVersion>, then a second commit that removes the pin after the C# 14 audit.

Ask who has code with span overloads today. Most teams underestimate this because the overloads are often in shared libraries, not application code. Frame the module as a design and upgrade discussion, not a feature parade.

Extension members are the main event C# 14

Classic extension methods let you add methods. Extension blocks let you add a small, coherent member surface.

C# 13 shape
public static class OrderExtensions
{
    public static bool IsEmpty(
        this IReadOnlyCollection<OrderLine> lines)
        => lines.Count == 0;

    public static decimal Total(
        this IEnumerable<OrderLine> lines)
        => lines.Sum(line => line.Price);
}
C# 14 shape
public static class OrderExtensions
{
    extension(IReadOnlyCollection<OrderLine> lines)
    {
        public bool IsEmpty => lines.Count == 0;
    }

    extension(IEnumerable<OrderLine> lines)
    {
        public decimal Total =>
            lines.Sum(line => line.Price);
    }
}

The extension block is declared inside a top-level, non-generic, non-nested static class. The receiver is named once, then used by every instance member in the block.

Classic extension methods still matter

The old this-parameter form remains valid. Keep it for one-off methods or when changing syntax would only churn a stable API.

Demo by converting one familiar helper method into a property. Ask the room whether lines.Total reads like stored state or a calculation; that distinction drives whether a property is appropriate.

Get the extension syntax exactly right C# 14

There are two receiver forms. Use a named receiver for instance members; use a type-only receiver when the block contains only static members.

Instance extension block
public static class CustomerExtensions
{
    extension(Customer customer)
    {
        public bool HasEmail =>
            !string.IsNullOrWhiteSpace(customer.Email);

        public string DisplayLabel() =>
            $"{customer.Id}: {customer.Name}";
    }
}

Called as customer.HasEmail and customer.DisplayLabel().

Static extension block
public static class CustomerExtensions
{
    extension(Customer)
    {
        public static Customer Anonymous =>
            new("anonymous");
    }
}

Called as Customer.Anonymous.

The receiver parameter is a real name in the block

Do not reuse it for method parameters, locals, or local functions inside the same block. Static extension members cannot use the named receiver because there is no instance.

Point out that the type-only block is easy to misread as a constructor. It is not. It is the way the language says "extend the type, not an instance".

Extension properties: powerful, but easy to overuse

The new capability is not shorter method syntax. It is the ability to make calculated facts read like facts.

public static class UriExtensions
{
    extension(Uri uri)
    {
        public bool IsHttps =>
            string.Equals(uri.Scheme, Uri.UriSchemeHttps,
                StringComparison.OrdinalIgnoreCase);

        public string Origin =>
            uri.IsDefaultPort
                ? $"{uri.Scheme}://{uri.Host}"
                : $"{uri.Scheme}://{uri.Host}:{uri.Port}";
    }
}
Good property

Cheap, deterministic, side-effect free, and reads as a quality of the receiver.

Use a method

Expensive, asynchronous, mutating, parameterised, or surprising if evaluated repeatedly.

Document it

Consumers cannot tell whether an extension property enumerates a sequence unless you make that obvious.

Properties can hide work

orders.Total might enumerate a database-backed sequence. If the computation is not obviously cheap, prefer CalculateTotal().

Ask the room for helper methods ending in Is, Has, Can, or Count. Those are candidates, but not automatic conversions.

Static extension members move factories closer to the type

Static extensions appear as static members of the receiver type. That can make neutral values, factories, and operators discoverable without owning the original type.

public static class MoneyExtensions
{
    extension(Money)
    {
        public static Money Zero => new(0m, "EUR");

        public static Money FromEuros(decimal amount) =>
            new(amount, "EUR");
    }
}

Money invoiceMinimum = Money.Zero;
Money deposit = Money.FromEuros(250m);
Use this for domain vocabulary, not as a dumping ground

If a member is only meaningful to one bounded context, keep it in that namespace. Extension members are still opt-in through using; they are not a way to globally patch the BCL.

Discoverability cuts both ways

Putting too many static extension members on a common type makes completion lists noisy. Prefer a small set that makes call sites more honest.

Good live demo: create Money.Zero, then remove the namespace import and show that it disappears. This avoids the misconception that the type itself has been modified.

What extension members do not change

Extension members improve expression. They do not change ownership, encapsulation, or binding priority.

RulePractical consequence
They live in static extension containersYou still import a namespace before the members are available
They cannot override real membersAn instance or static member declared on the type wins over an extension member
They obey the extended type's accessibilityNo private-field access and no encapsulation bypass
Extension properties cannot use init accessorsDo not model object construction through extension properties
Extension blocks are not namespacesMembers in the same extension container still need unique signatures
public sealed class Customer
{
    public string Label => "real member";
}

public static class CustomerExtensions
{
    extension(Customer customer)
    {
        public string Label => "extension member";
    }
}

Console.WriteLine(new Customer().Label); // real member wins

Stress that this is why extension members are safe for framework types. They add conveniences when no better member exists; they do not let library authors hijack existing calls.

The field keyword C# 14

The new contextual keyword gives an accessor direct access to the compiler-synthesised backing field. It replaces the most repetitive full-property pattern.

Before
private string _name = string.Empty;

public string Name
{
    get => _name;
    set => _name = value.Trim();
}
C# 14
public string Name
{
    get;
    set => field = value.Trim();
}

You can mix an auto accessor with a full accessor. The backing field is still generated by the compiler, but the custom accessor can validate, normalise, lazy-initialise, or raise notifications.

public string Email
{
    get;
    set => field = value.Trim().ToLowerInvariant();
}

public Report Summary
{
    get => field ??= BuildReport();
}

Ask the room to estimate how many manual backing fields exist in their main solution. The likely answer is "too many". This feature is a readability refactor, not a business logic change.

field can shadow a real member Audit

This is rare, but it is the kind of rare that ships: the compiler reports only a warning.

private int field = 100;

public int Value
{
    get => field;
    set => field = value;
}

public int RawField => field;
Measured on SDK 10.0.300, runtime 10.0.12

With the real member above, C# 13 printed 100, 100, then 7, 7. C# 14 printed 0, 0, then 7, 0 and emitted diagnostic CS9258.

100, 100, then 7, 7
0, 0, then 7, 0
Exact compiler text

"In language version 14.0, the 'field' keyword binds to a synthesized backing field for the property. To avoid generating a synthesized backing field, and to refer to the existing member, use 'this.field' or '@field' instead."

Promote CS9258 to an error before allowing C# 14 in CI.

Explain the final zero: RawField is itself a property, so field inside it binds to that property's own synthesised backing field in C# 14. This is the part people miss.

First-class spans: the feature is overload resolution

C# 14 treats span conversions as standard implicit conversions in more places. Arrays, strings, Span<T>, and ReadOnlySpan<T> compose more naturally.

void Parse(ReadOnlySpan<char> text) { }
void Process(ReadOnlySpan<int> values) { }

string name = "Ada";
int[] numbers = [1, 2, 3];
Span<int> writable = numbers;

Parse(name);       // string -> ReadOnlySpan<char>
Process(numbers);  // int[] -> ReadOnlySpan<int>
Process(writable); // Span<int> -> ReadOnlySpan<int>
Why the language changed

The BCL increasingly exposes span-based APIs. Without first-class conversions, library authors needed duplicate overloads or callers needed explicit .AsSpan() just to reach the faster path.

Make clear that spans themselves are not new. What changed is where the compiler considers the conversions, especially for arguments and extension receivers.

The real span break: calls silently move Behaviour

This is the most dangerous C# 14 item in the release because correct existing code can bind to a different overload with no diagnostic.

static void M(IEnumerable<int> values) =>
    Console.WriteLine("IEnumerable");

static void M(ReadOnlySpan<int> values) =>
    Console.WriteLine("ReadOnlySpan");

int[] numbers = [1, 2, 3];
M(numbers);
Measured fact: no warning, no error

With LangVersion 13.0, the int[] call bound to M(IEnumerable<int>). With LangVersion 14.0, the same source bound to M(ReadOnlySpan<int>). The same movement was verified for extension-method calls.

This is not just a performance change

If the two overloads have different validation, logging, allocation, culture, or enumeration semantics, the language upgrade changes behaviour.

Do not soften this. It is the slide that justifies the audit. Ask who owns shared utility APIs with both enumerable and span overloads.

Null arrays can become empty spans Audit

The behavioural break is clearest when callers pass a null array to an overload set containing IEnumerable<T> and ReadOnlySpan<T>.

static void Validate(IEnumerable<int> values)
{
    ArgumentNullException.ThrowIfNull(values);
}

static void Validate(ReadOnlySpan<int> values)
{
    if (values.IsEmpty)
        return;
}

int[]? numbers = null;
Validate(numbers);
Measured fact: throw becomes no-op

C# 13 selected the IEnumerable<int> overload, received null, and the guard threw ArgumentNullException. C# 14 selected the ReadOnlySpan<int> overload, produced a span with Length 0 and IsEmpty True, and the guard never fired.

Audit shape

Find public or shared APIs offering both IEnumerable<T> and ReadOnlySpan<T>. Review array call sites, especially ones where null used to be invalid input.

Emphasise that the span itself is safe; the risk is that null and empty are often distinct business states. This is where tests should be added before retargeting.

Two reassurances: the audit is narrower than it sounds

Do not turn this into "all arrays are dangerous". Two common fears did not reproduce.

int[].Reverse() did not switch — on net10.0

Measured on net10.0 under C# 14: int[].Reverse() still binds to Enumerable.Reverse, still returns a lazy iterator, and does not reverse in place. That qualifier is load-bearing — see the next slide.

An exact array overload still wins

Measured under both language versions: an exact T[] overload beats ReadOnlySpan<T>. The silent movement is the IEnumerable<T> case.

Overload setPre-upgrade concern
M(T[]) and M(ReadOnlySpan<T>)Lower priority; exact array overload remains selected
M(IEnumerable<T>) and M(ReadOnlySpan<T>)High priority; array calls can move silently
Only ReadOnlySpan<T>Usually fine; C# 14 simply removes explicit conversion noise

This slide prevents panic. The recommended search is targeted: APIs with both enumerable and span shapes, not every use of Span. But do not end the topic here — the next slide is where the reassurance runs out.

The shortcut that silently reverses your arrays

Someone will ask it within ten minutes of the retargeting slide: "can we just set LangVersion to 14 and keep net8.0?" It compiles. Do not do it.

Target framework and language version are separate dials, and the SDK will let you turn one without the other. Every C# 14 feature we have seen so far builds and runs on net8.0 with <LangVersion>14.0</LangVersion> — extension members, field, null-conditional assignment, unbound nameof. Zero errors, zero warnings.

Then you write this, which is ordinary, discard-the-result LINQ:

int[] nums = [3, 1, 2];

nums.Reverse();   // a bare statement

Console.WriteLine(string.Join(",", nums));
Measured: same file, same TFM, same SDK — only the dial moved
net8.0 + LangVersion 13.0  ->  [3,1,2]
net8.0 + LangVersion 14.0  ->  [2,1,3]

  0 Warning(s)
  0 Error(s)

Under C# 13 this is a lazy Enumerable.Reverse whose result is discarded — a no-op. Under C# 14 the array converts to Span<int> and binds to MemoryExtensions.Reverse, which mutates in place and returns void. The caller's array is reversed. Nothing is reported.

Why net10.0 is safe and net8.0 is not

The mitigation for this shipped in the .NET 10 BCL, as compensating overloads that keep array receivers on the LINQ operators. A new compiler against an old BCL is the one combination where the guard rail is missing. It is not that C# 14 is dangerous — it is that C# 14 without .NET 10 is.

And Microsoft does not support it anyway

The Configure the language version documentation states plainly that setting a language version newer than the target framework's default is unsupported and is not an upgrade path. So the answer in the room is: retarget to net10.0 to get C# 14. If a library genuinely must stay on net8.0, pin <LangVersion>13.0</LangVersion> explicitly rather than leaving it to a default.

This is the demo to run live if you run only one in module 2. Two builds, one line of output each, and the audience watches an array change contents with no diagnostic. It also closes the loop on the previous slide: the reassurance was real, but it was conditional on the BCL, and nobody reads release notes that carefully.

If someone insists on the shortcut, the deliverable is an audit of every .Reverse(), .Contains() and array-to-span-adjacent call site. That usually ends the conversation.

Null-conditional assignment: the whole write short-circuits

?. and ?[] can now appear on the left-hand side of assignment and compound assignment.

Before
if (holder is not null)
{
    holder.Name = Loud();
}
C# 14
holder?.Name = Loud();

customer?.Orders += 1;
array?[index] = value;
Measured fact: the right-hand side is skipped

Given h?.Name = Loud(); where h is null, Loud() was never called. It is not "evaluate the RHS then skip the write"; the entire assignment is skipped.

Limits

Compound assignment is supported. Increment and decrement are not: customer?.Count++ and --customer?.Count do not compile.

Ask people to predict whether Loud() runs. Many assume assignment evaluates RHS first. This is a useful quick quiz before showing the measured result.

nameof now accepts unbound generics

When you want the generic type name rather than a constructed type, you no longer need a representative type argument.

Console.WriteLine(nameof(List<>));
Console.WriteLine(nameof(Dictionary<,>));

string metric = $"cache.miss.{nameof(Dictionary<,>)}";
Measured outputs

nameof(List<>) evaluated to "List", and nameof(Dictionary<,>) evaluated to "Dictionary".

List
Dictionary
Where it helps

Logging, metrics, diagnostics, analyser messages, and attributes where the generic type definition matters and the type arguments are noise.

Keep this short. It is useful and safe, but it should not steal time from spans and extension members.

Lambda parameter modifiers no longer force full types

Before C# 14, adding out, ref, in, ref readonly, or scoped meant spelling out parameter types. Now the compiler can infer them in simple lambda parameter lists.

delegate bool TryParse<T>(string text, out T result);

// Before
TryParse<int> parse1 =
    (string text, out int result) =>
        int.TryParse(text, out result);

// C# 14
TryParse<int> parse2 =
    (text, out result) =>
        int.TryParse(text, out result);
Measured syntax

The simplified form (s, out result) => ... compiled and behaved as documented under C# 14.

params is still different

The params modifier still requires an explicitly typed parameter list. Do not generalise this feature to every modifier you can write on a parameter.

Useful place to mention code style. If explicit types make a complex delegate clearer, keep them. The feature removes ceremony; it is not a mandate.

Partial properties and events close generator gaps

Partial members let one file declare the public shape and another file, often generated, supply the implementation.

User-authored declaration
public partial class CustomerViewModel
{
    public partial string DisplayName { get; set; }

    public partial event EventHandler? Saved;
}
Generated implementation
public partial class CustomerViewModel
{
          private EventHandler? _saved;

          public partial string DisplayName
          {
              get => field;
        set => field = value.Trim();
    }

    public partial event EventHandler? Saved
    {
        add { _saved += value; }
        remove { _saved -= value; }
    }
}
Measured feature set

Partial properties and partial events both compiled and behaved as documented under C# 14.

Primarily a source-generator feature

Most application code should not split properties or events by hand. This is valuable when generated code owns plumbing and human code owns intent.

Do not over-teach source generators here. The key message is that C# 14 removes awkward generator workarounds for events; partial properties are also part of the modern partial-member story.

User-defined compound assignment is for mutation hot paths

C# 14 lets a type customise compound assignment directly, instead of relying only on x = x + y expansion.

public struct Accumulator
{
    private long _total;

    public void operator +=(long amount)
    {
        _total += amount;
    }

    public override string ToString() => _total.ToString();
}
First-party spec shape

The compound assignment operator is an instance operator, returns void, and takes the right-hand operand. Use it when in-place mutation is the semantic model you actually want.

Do not use this to make mutable code look immutable

If callers expect value-like behaviour, a mutating += can surprise them. The strongest cases are accumulators, buffers, tensors, and large structs where avoiding copies is the point.

Say that this slide is based on first-party specification, not one of the measured workshop corrections. If challenged on syntax, show the source list rather than improvising extra forms.

What this means for your upgrade

Most C# 14 features are opt-in. Two are not: default language version and overload resolution.

  1. Inventory overload sets. Search shared APIs for pairs of IEnumerable<T> and ReadOnlySpan<T>. Add tests for null arrays and arrays with values.
  2. Promote CS9258. Treat the field shadowing warning as an error before adopting field-backed properties.
  3. Pin if you need sequencing. Retarget to net10.0 with <LangVersion>13.0</LangVersion>, validate runtime changes, then remove the pin.
  4. Adopt extension members where they improve API shape. Prefer domain vocabulary over helper-class churn.
  5. Keep convenience features boring. nameof(List<>), null-conditional assignment, and lambda modifier inference should make code easier to read, not cleverer.
The practical pre-upgrade search

Do not search for every span-capable call. Search for overload sets where an array could choose between IEnumerable<T> and ReadOnlySpan<T>. That is where the measured behaviour moved.

Close by asking each table to name one repository where the span audit belongs. If nobody can name an owner, the upgrade plan is incomplete.

Lab 01 — C# 14 in anger 30 min

Take a deliberately old-fashioned type and modernise it.

cd labs/Day1/01-CSharp14/start
dotnet run

You are given a small order-processing library written in C# 7 style. Your tasks:

  1. Replace every manual backing field with the field keyword.
  2. Convert the OrderHelpers static class into an extension block, turning at least two of its methods into extension properties.
  3. Add a static extension member that returns an empty order.
  4. Find the three places that can use null-conditional assignment.
  5. Make the Money struct support in-place +=.

The solution is in labs/Day1/01-CSharp14/solution. Tests in the start project must keep passing throughout — that's the point.

Attendees who finish early: point them at the breaking change. The starter code contains a member named field that compiles cleanly in C# 13 and changes meaning under C# 14. Nobody spots it unless prompted.

.NET 10 libraries .NET 10

The library story is defensive modernisation: stronger cryptography choices, stricter JSON boundaries, and a few everyday APIs that remove local plumbing.

PQC

ML-KEM, ML-DSA, SLH-DSA and composite ML-DSA arrive in the BCL, but platform support and experimental markers are part of the programming model.

JSON

System.Text.Json gets strict defaults, duplicate-property rejection, source-generation reference handling, and direct PipeReader deserialisation.

Breadth

Certificate lookup, PEM scanning, PFX export control, async ZIP, numeric string ordering and WebSocketStream are the practical supporting cast.

Spend time where risk moves

The features that matter most are boundary features: cryptographic negotiation, key storage, request parsing, and payload ambiguity. They are where old assumptions become production incidents.

Ask who in the room owns crypto choices and who owns JSON request validation. They are often different teams. Set the expectation that this module is not a catalogue: it is a risk review with code-shaped examples.

Post-quantum crypto: why application teams care now

The near-term risk is not that a quantum computer breaks your service today. It is harvest now, decrypt later.

Threat model

An attacker records encrypted traffic or stored encrypted data now, then waits until the key-establishment algorithm is weak enough to recover secrets later.

Where it matters
  • Long-lived personal, financial, health or legal data.
  • Secrets with multi-year value: signing roots, backups, identity material.
  • Protocols and appliances that take years to replace.
Do not turn this into panic migration

PQC is urgent for long-lived confidentiality and slow-moving infrastructure. It is not an argument that every internal JSON API needs a new cryptographic protocol this quarter.

Keep the message calm. Ask attendees which data in their estate still has value in 2036. If they only name access tokens and short-lived telemetry, say so: those are not the strongest PQC drivers.

Do not conflate the PQC algorithms

.NET 10 exposes several post-quantum families. They solve different problems and should not appear in the same sentence as if they are interchangeable.

Algorithm familyStandardUseWhat it does not do
MLKemML-KEM, FIPS 203Key encapsulation: establish a shared secretIt does not sign data
MLDsaML-DSA, FIPS 204Digital signaturesIt does not establish a shared secret
SlhDsaSLH-DSA, FIPS 205Stateless hash-based signaturesIt is not uniformly available
CompositeMLDsaIETF composite-signature workMigration signatures combining ML-DSA with a classical algorithmIt is not a blanket replacement for TLS negotiation
Measured assembly surface

System.Security.Cryptography version 10.0.0.0 contains 15 PQC-related types, including platform-specific *Cng and *OpenSsl variants. What ships is broader than the three headline algorithm names.

The API shape follows the cryptography

The new types do not derive from AsymmetricAlgorithm. A KEM is not encrypt/decrypt, and a signature API is not key exchange with a different name.

Ask someone to explain the difference between encryption, key agreement and signing. If the distinction is fuzzy, slow down here. Most misuse later in the module comes from treating ML-KEM as asymmetric encryption.

ML-KEM gives you a symmetric secret, not ciphertext for your message

The recipient owns a key pair. The sender imports the public key, encapsulates, and sends the encapsulation ciphertext. Both sides end with the same 32-byte secret.

using System.Security.Cryptography;

if (!MLKem.IsSupported)
{
    throw new PlatformNotSupportedException("ML-KEM is not available here.");
}

using MLKem recipient = MLKem.GenerateKey(MLKemAlgorithm.MLKem768);

#pragma warning disable SYSLIB5006 // Key serialisation is still experimental.
byte[] spki = recipient.ExportSubjectPublicKeyInfo();
using MLKem sender = MLKem.ImportSubjectPublicKeyInfo(spki);
#pragma warning restore SYSLIB5006

sender.Encapsulate(out byte[] ciphertext, out byte[] senderSecret);

byte[] recipientSecret = recipient.Decapsulate(ciphertext);
// Feed senderSecret / recipientSecret into symmetric cryptography.
Measured ML-KEM-768 round trip

This shape produced a 1206-byte SPKI public key, a 1088-byte ciphertext and a 32-byte shared secret, and both sides derived an identical secret. The two #pragma lines are not decoration: without them this exact sample fails to build with two SYSLIB5006 errors. The next slide explains why.

Demo this as a round trip, not as encryption. Say the 32-byte secret is the payload you take to AES or another symmetric construction. Do not design a protocol live; focus on API mechanics and sizes.

The cost is bigger keys and signatures

PQC is not just a new enum value. The byte counts affect certificates, handshakes, tokens, QR codes, headers and storage schemas.

Measured itemSizeDesign implication
ML-KEM-768 SPKI public key1206 bytesAgainst 32 bytes for an X25519 public key, or 64 for an uncompressed P-256 one
ML-KEM-768 raw encapsulation key1184 bytesThe FIPS 203 format, without the 22 bytes of ASN.1 wrapper — and it is the stable API
ML-KEM-768 ciphertext1088 bytesEvery encapsulation carries a visible bandwidth cost
ML-KEM shared secret32 bytesThe output is compact because it is intended for symmetric cryptography
ML-DSA-65 signature3309 bytesAgainst 64 for Ed25519 — this is the number that breaks JWTs and QR codes
Measure before you commit a wire format

Measured on .NET 10.0.12 with MLKemAlgorithm.MLKem768 and MLDsaAlgorithm.MLDsa65. They are why PQC migration is also a protocol and storage migration — and why a 3309-byte signature deserves a slide of its own in any design review that involves a bearer token.

Ask who has maximum header sizes, token-size limits or certificate pinning logic. This is where the conversation becomes concrete. The cost is acceptable in many places, but only if it is visible during design.

IsSupported is real control flow

The BCL type existing does not mean the current machine can run the algorithm. .NET delegates support to the underlying cryptographic provider, such as Windows CNG with PQC support or OpenSSL 3.5 or later.

Measured Windows support matrix
MLKem.IsSupported -> True
MLDsa.IsSupported -> True
CompositeMLDsa.IsSupported -> True
SlhDsa.IsSupported -> False

SlhDsa.GenerateKey(...) throws:
PlatformNotSupportedException: Algorithm 'SlhDsa' is not supported on this platform.
if (!SlhDsa.IsSupported)
{
    // Hide the feature, choose a different algorithm, or fail startup clearly.
    return;
}
This will bite demos and CI

Different OS images can produce different answers. Treat support probing as required application code, not as defensive decoration around a theoretically portable API.

Run the support probe live if time allows. If an attendee gets a different result, use it: that is the lesson. Avoid promising that a cloud runner, developer laptop and production host agree.

"PQC is experimental" is too coarse

The boundary does not run around the types. It runs through them. On MLKem and MLDsa, SYSLIB5006 is applied per member — so the same class gives you members that compile silently and members that stop the build.

Stable — the cryptography
MLKem.GenerateKey(...);
key.Encapsulate(out ct, out secret);
key.Decapsulate(ct);
key.ExportEncapsulationKey();

MLDsa.GenerateKey(...);
signer.SignData(data);
signer.VerifyData(data, sig);

Measured: zero diagnostics, no suppression.

Experimental — the serialisation
key.ExportSubjectPublicKeyInfo();
MLKem.ImportSubjectPublicKeyInfo(spki);
// ...and the PEM / PKCS#8 / X.509 paths

bool ok = SlhDsa.IsSupported;   // whole type
CompositeMLDsa.GenerateKey(...); // whole type

Measured: SYSLIB5006, reported as an error.

Measured compiler boundary
error SYSLIB5006: 'MLKem.ExportSubjectPublicKeyInfo()' is for evaluation
purposes only and is subject to change or removal in future updates.
error SYSLIB5006: 'MLKem.ImportSubjectPublicKeyInfo(byte[])' is for evaluation
purposes only and is subject to change or removal in future updates.

  0 Warning(s)
  2 Error(s)

Generate, encapsulate, decapsulate, sign and verify built clean in the same project. SlhDsa and CompositeMLDsa are experimental at type level, so even reading IsSupported on them is an error.

What this actually tells you

Read the split as a roadmap signal rather than a nuisance. The algorithms are final — FIPS 203, 204 and 205 are published. What is still moving is how .NET encodes keys on the wire and on disk. So the risk in adopting PQC today is not that your ciphertext becomes invalid; it is that your stored key format does.

Suppress narrowly, and never project-wide

A <NoWarn>SYSLIB5006</NoWarn> in the csproj hides exactly the call sites you will need to revisit at the next major upgrade. Scope #pragma warning disable to the serialisation lines so the compiler can hand you the list later.

Expect someone to ask whether all PQC is preview. Answer with the measured split: the maths is stable, the encodings are not. If they push further, the honest line is that CompositeMLDsa's wire format changed after RC 2, which is precisely why it is type-level experimental.

System.Text.Json becomes stricter at the boundary

The serializer is increasingly a boundary component, not just a convenience API for turning objects into text.

Before

Application code often accepted ambiguous input, ignored unknown fields, and relied on model validation to notice missing required values later.

.NET 10 posture

You can choose strict defaults that reject duplicate names, unknown members, nullable mismatches and missing required constructor parameters at deserialisation time.

Item item = JsonSerializer.Deserialize<Item>(
    json,
    JsonSerializerOptions.Strict)!;
Strict is opt-in

This does not silently change your existing services. You decide where stricter parsing belongs, then handle the failures as input-contract failures.

Ask where JSON enters their system before the app sees it: gateway, WAF, queue, logging middleware, schema validator. The more parsers involved, the stronger the case for rejecting ambiguity early.

JsonSerializerDefaults.Strict changes four things

Microsoft documents the intent. The workshop measurement diffed the actual options objects so you can teach the precise behaviour.

PropertyGeneralStrict
AllowDuplicatePropertiesTrueFalse
RespectNullableAnnotationsFalseTrue
RespectRequiredConstructorParametersFalseTrue
UnmappedMemberHandlingSkipDisallow
JsonSerializerOptions shared = JsonSerializerOptions.Strict;
JsonSerializerOptions local = new(JsonSerializerDefaults.Strict);
Measured options diff

Those four properties are the exact differences between General and Strict on the workshop machine. Nothing else differed. Both the static JsonSerializerOptions.Strict instance and the JsonSerializerDefaults.Strict enum member exist.

Point out that case sensitivity is already part of General, so it does not appear in the diff. This avoids overstating Strict as a magic security mode. It is a small set of concrete switches.

Duplicate JSON properties are a security problem

Duplicate names create parser differentials: one component may log or authorise one value while another component executes a different value.

Payload
{"A":1,"A":2}
Model
public sealed record Item(int A);
Measured duplicate-property behaviour

Default options silently succeeded with A = 2: last one wins, with no error and no warning. Strict threw JsonException: Duplicate property 'A' encountered during deserialization of type 'Item'.

Frame this as boundary security

Rejecting duplicates is not tidiness. It removes an ambiguity attackers can use for request smuggling, policy bypass and audit-log confusion.

Ask whether their body logger records the first occurrence or the final model value. If nobody knows, that is the point. This is usually the JSON slide that gets the most nods from security and platform engineers.

Strict mode makes DTO contracts executable

The other three strict switches move common review comments from convention into runtime behaviour.

Unknown fields

JsonUnmappedMemberHandling.Disallow rejects payload members that your contract does not declare.

Nullable annotations

RespectNullableAnnotations makes non-nullable reference annotations matter during serialisation and deserialisation.

Constructors

RespectRequiredConstructorParameters treats non-optional constructor parameters as required input.

var options = new JsonSerializerOptions(JsonSerializerDefaults.Strict)
{
    TypeInfoResolver = MyJsonContext.Default
};
Be precise about what is actually new

RespectNullableAnnotations and RespectRequiredConstructorParameters both shipped in .NET 9, and almost nobody turned them on. What .NET 10 adds is JsonSerializerDefaults.Strict — a single, named, reviewable switch that enables them together with duplicate-property rejection and unmapped-member rejection. The value is in the default, not in the individual flags. Say so; an experienced room will already know the flags, and claiming them as new costs you credibility for the rest of the module.

Strict can break bad clients

That is the point, but it still needs a rollout plan. Start with public request boundaries where ambiguity is dangerous, then decide whether compatibility windows or versioned endpoints are needed.

Do not claim Strict validates business rules. It validates JSON-to-contract shape. Keep domain validation separate: credit limits, permissions and cross-field rules still belong elsewhere.

Source-generated JSON can preserve references

.NET 10 lets source-generated contexts specify reference handling through JsonSourceGenerationOptionsAttribute, instead of falling back to runtime options for cyclic graphs.

[JsonSourceGenerationOptions(
    ReferenceHandler = JsonKnownReferenceHandler.Preserve)]
[JsonSerializable(typeof(Node))]
internal partial class GraphJsonContext : JsonSerializerContext
{
}

internal sealed class Node
{
    public Node? Parent { get; set; }
    public List<Node> Children { get; } = [];
}
Use this for explicit graph contracts

Reference preservation changes the JSON shape by adding metadata such as reference identifiers. It is useful when object identity is part of the contract, not as a default for every DTO.

Ask whether attendees want tree-shaped JSON or graph-shaped JSON. Many cycles are accidental EF entities leaking over the wire. This API helps when identity is intentional; it does not make domain objects good API contracts.

JSON now reads directly from PipeReader

JsonSerializer.DeserializeAsync and async-enumerable deserialisation can consume a PipeReader directly, matching the existing PipeWriter serialisation direction.

using System.IO.Pipelines;
using System.Text.Json;

PipeReader reader = connection.Input;
Order? order = await JsonSerializer.DeserializeAsync<Order>(
    reader,
    JsonSerializerOptions.Strict,
    cancellationToken);
Before

You adapted a pipeline to a Stream or copied buffers into an intermediate shape before deserialising.

Now

The serializer can sit directly on the pipeline in transports, proxies and high-throughput services.

Only dwell here if the room has service or networking teams. For typical controller-based apps this is interesting but not urgent. Tie it to ASP.NET Core internals and custom transports if needed.

Crypto improvements beyond PQC

Several smaller cryptography changes remove old assumptions that have lingered in platform code.

Certificates and PEM
  • X509Certificate2Collection.FindByThumbprint can match thumbprints using a named hash algorithm such as SHA-256.
  • PemEncoding.FindUtf8 scans ASCII/UTF-8 PEM data without first converting bytes to chars.
Export and wrapping
  • ExportPkcs12 overloads let callers choose PFX encryption and digest algorithms.
  • Aes supports AES Key Wrap with Padding for RFC 5649-style key wrapping scenarios.
Useful upgrade review item

Search platform code for SHA-1 thumbprint assumptions, PEM byte-to-string conversion, and hard-coded PFX compatibility choices. These are low-drama fixes with clear ownership.

Pick whichever item maps to the room. Platform teams usually care about certificate lookup and PFX export. Product teams usually care less unless they ship agents, installers or appliances.

The library changes most teams will actually touch

These are not headline features, but they replace common helper code and performance workarounds.

Area.NET 10 changeWhy you care
ZIPAsync APIs such as ZipFile.OpenReadAsync and ZipArchiveEntry.OpenAsyncLarge archive work no longer has to block request or UI threads
CollectionsOrderedDictionary<TKey,TValue> overloads return the entry index from TryAdd and TryGetValueUpdate ordered data without a second lookup
GlobalisationCompareOptions.NumericOrderingSort file2 before file10 without custom comparers
WebSocketsWebSocketStreamUse stream-shaped APIs over WebSocket messages and transports
DiagnosticsTelemetry schema URLs, richer out-of-process Activity data and rate-limit samplingMake traces easier to govern, not just more verbose

Do not read every row. Ask which helper classes they already own. If they have archive processing, numeric file names or custom WebSocket framing, this slide gives them a concrete cleanup backlog.

What to actually do after this module

Treat .NET 10 libraries as an adoption backlog, not as a feature checklist.

  1. Inventory long-lived confidentiality. Identify data and protocols where harvest-now-decrypt-later is credible.
  2. Probe PQC support in every target environment. Put IsSupported in code paths and deployment checks, not just demos.
  3. Separate stable and experimental PQC usage. Keep SYSLIB5006 suppressions narrow and reviewed.
  4. Harden JSON boundaries first. Start with duplicate-property rejection and strict options on public or partner-facing inputs.
  5. Retire local helpers. Replace custom archive, ordering, PEM, thumbprint and WebSocket plumbing where the BCL now owns the behaviour.
The safest first pull request

Add JSON duplicate-property rejection at the edge, with tests that show the previous last-one-wins behaviour. It is easy to review and teaches the team what boundary hardening looks like.

Close by asking each team to name one owner: crypto inventory, JSON boundary hardening, or helper cleanup. The goal is a first small PR, not a six-month platform programme.

Lab 02 — PQC and JSON hardening 30 min

Use the new APIs as boundary code: capability-gated crypto first, then a stricter JSON input path.

cd labs/Day1/02-Libraries-Pqc-Json/start
dotnet run
  1. Gate PQC features with IsSupported and print the local support matrix.
  2. Generate an ML-KEM key pair, encapsulate a shared secret and decapsulate it.
  3. Sign and verify a payload with ML-DSA, then prove a tampered payload fails.
  4. Harden a JSON deserializer with strict options and duplicate-property rejection.
Expected surprise

On this machine SLH-DSA reports unsupported. Do not "fix" that in the lab. The unsupported branch is the lesson.

Keep the crypto code small. The goal is not to teach cryptographic protocol design; it is to teach how the .NET 10 APIs behave, where platform support comes from, and how to avoid assuming that every algorithm is present everywhere.

OpenAPI 3.1 is now the default .NET 10

The most visible ASP.NET Core 10 change is not an endpoint API. It is the contract your tools read.

Treat the document as a compatibility surface

Microsoft.AspNetCore.OpenApi now emits OpenAPI 3.1 by default, aligning schemas with JSON Schema draft 2020-12. That changes the JSON shape even when your HTTP behaviour is unchanged.

What changes
  • Nullable values move from nullable: true to type arrays.
  • Nullable complex types and collections can use oneOf.
  • $ref sibling descriptions can now survive.
  • Numbers and dates are emitted using invariant culture.
What breaks first
  • Exact version assertions in contract tests.
  • Client generators that only understand OpenAPI 3.0.
  • Diff pipelines that assume nullable and simple integer schemas.
{
  "note": { "type": ["null", "string"] }
}

Ask who generates clients, gateway policies or contract tests from OpenAPI. Set the tone: we are not saying 3.1 is bad; we are saying the document is executable input to other tools and must be tested like code.

Pin the family, not the patch digit

The option is straightforward, but the emitted patch version is selected by the framework.

Measured on .NET 10 SDK 10.0.300 and runtime 10.0.12
  • AddOpenApi() with no configuration emits "openapi": "3.1.1".
  • OpenApiSpecVersion.OpenApi3_0 emits "openapi": "3.0.4".
  • OpenApiSpecVersion.OpenApi3_1 emits "openapi": "3.1.1".
  • The enum requires using Microsoft.OpenApi;.
using Microsoft.OpenApi;

builder.Services.AddOpenApi(options =>
{
    options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0;
});
Do not string-match 3.1.0

The option selects the OpenAPI minor-version family. It does not freeze the patch digit that appears in the generated document.

Demo the first line of the document before any schemas. This is where brittle CI checks fail quickly. Then point out that pinning is a bridge for tooling, not a reason to ignore the rest of the migration.

The real schema diff: nullability and integers

This is the before/after to show generator owners. It came from the same record emitted as 3.1.1 and pinned 3.0.4.

public record Order(
    int Id,
    string? Note,
    int? Quantity,
    Priority Priority,
    DateTimeOffset Placed);
OpenAPI 3.1.1 default
"id":       { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": ["integer","string"], "format": "int32" }
"note":     { "type": ["null","string"] }
"quantity": { "pattern": "^-?(?:0|[1-9]\\d*)$", "type": ["null","integer","string"], "format": "int32" }
"placed":   { "type": "string", "format": "date-time" }
OpenAPI 3.0.4 pinned
"id":       { "pattern": "^-?(?:0|[1-9]\\d*)$", "anyOf": [ {"type":"integer"}, {"type":"string"} ], "format": "int32" }
"note":     { "type": "string", "nullable": true }
"quantity": { "pattern": "^-?(?:0|[1-9]\\d*)$",
              "anyOf": [ {"type":"integer"}, {"type":"string"}, {"enum":[null],"nullable":true} ],
              "format": "int32", "nullable": true }
Measured fact, not a schematic

The 3.1 form collapses the 3.0 nullable anyOf shape into a type list. The integer-or-string shape comes from the ASP.NET Core JSON default that permits reading numbers from strings.

Spend time on the Quantity line. It shows why a generator can produce a different client without any endpoint code changing. If someone asks for the fix, preview the strict-number slide rather than hand-waving.

The integer schema is really a JSON option story

The OpenAPI 3.1 output reflects ASP.NET Core's runtime JSON behaviour: numbers may be read from strings by default.

Why generators can surprise you

An int or long can appear as integer-or-string in the schema. A client generator that expects a single type: integer may generate a wider or less useful client shape.

using System.Text.Json.Serialization;

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict;
});
What this fixes

The generated schema no longer needs to advertise that numeric values may arrive as JSON strings.

What this changes

Runtime binding becomes stricter. A request body that previously accepted "42" for an integer can now fail. This is a compatibility decision, not a cosmetic OpenAPI tweak.

Minimal APIs and MVC use different knobs

ConfigureHttpJsonOptions covers Minimal APIs. Controller-based APIs configure JSON through MVC's JSON options.

Ask whether their APIs intentionally accept quoted numbers. If yes, the schema is telling the truth and client generation must cope. If no, strict number handling is a behavioural clean-up to test deliberately.

Enums silently degrade unless JSON options say otherwise

The contract does not infer friendly enum values from your C# enum by itself.

Default enum schema
{ "type": "integer" }
After string enum converter
{ "enum": ["Low", "High"] }
using System.Text.Json.Serialization;

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
Measured in both 3.0 and 3.1 output

Without the converter, public enum Priority { Low, High } emitted no values at all, only { "type": "integer" }. Generated clients get a bare integer until you configure string enums.

Ask whether their public contracts use numeric enums intentionally. If not, this is an easy workshop win: configure the serializer, regenerate the spec, and inspect generated client ergonomics.

Microsoft.OpenApi 2.0 rewrites your transformers

Pinning the document back to OpenAPI 3.0 does not pin the object model your code compiles against.

This applies even when output is pinned to 3.0

The .NET 10 package uses the Microsoft.OpenApi 2.0 object model. Existing document, operation and schema transformers must be reviewed regardless of the emitted OpenAPI version.

Old habit.NET 10 shapeWhy it matters
OpenApiAnyJsonNodeExamples and extension values change type.
Concrete model types everywhereInterfaces plus reference typesSchemas and security schemes may be inline or references.
OpenApiSchema.NullableNull in schema typeNullability is JSON Schema 2020-12 shaped.
Microsoft.OpenApi.ModelsMicrosoft.OpenApiNamespace imports change in common snippets.
schema.Example = new JsonObject
{
    ["temperatureC"] = 0,
    ["summary"] = "Bracing",
};

This is the trap slide. Pinning is a document-output choice; transformers are compiled against the new OpenAPI.NET library. Encourage teams to search for transformer code before retargeting.

Three transformer kinds, one execution pipeline

Use the smallest transformer that has the context you need.

Schema

AddSchemaTransformer runs as schemas are registered. It sees JsonTypeInfo and is right for examples, descriptions and type-level policy.

Operation

AddOperationTransformer runs per operation. It sees ApiDescription, so use it for endpoint metadata, auth exceptions and responses.

Document

AddDocumentTransformer runs last on the finished document. Use it for global info, servers, security schemes and final policy.

app.MapGet("/old", () => "This endpoint is old")
   .AddOpenApiOperationTransformer((operation, context, cancellationToken) =>
   {
       operation.Deprecated = true;
       return Task.CompletedTask;
   });
WithOpenApi is now ASPDEPR002

The old callback still compiles as a warning, but the replacement mutates the operation and returns Task.CompletedTask. For Swashbuckle generation, the documented equivalent remains an operation filter; for NSwag, an operation processor.

Ask for examples: tenant headers, correlation IDs, security requirements, examples. Then map each one to schema, operation or document. Point out that document transformers run after all operations and cannot cleanly make per-endpoint decisions.

XML comments now feed the OpenAPI document

A source generator turns existing XML documentation into contract metadata for the current assembly and project references.

<PropertyGroup>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
Will flow
  • <summary>, <remarks>, <param> and <returns>.
  • Methods, classes, members and [AsParameters] properties.
  • XML docs from referenced projects.
Will not flow
  • XML doc comments on lambda expressions.
  • Framework assembly docs unless you add the XML files as AdditionalFiles.
  • Some hard failures become missing metadata instead.
app.MapGet("/hello", Hello);

/// <summary>Sends a greeting.</summary>
/// <param name="name">The person to greet.</param>
static string Hello(string name) => $"Hello, {name}!";

Demo by extracting one Minimal API lambda into a named method. The gotcha is not ASP.NET Core; the C# build does not capture lambda XML comments, so inline handlers have nothing for the generator to read.

Generate the document at build time

Build-time generation turns the contract into an artefact you can diff, lint and publish from CI.

<PropertyGroup>
  <OpenApiGenerateDocumentsOnBuild>true</OpenApiGenerateDocumentsOnBuild>
  <OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)</OpenApiDocumentsDirectory>
  <OpenApiGenerateDocumentsOptions>--file-name orders --document-name v1 --openapi-version OpenApi3_1</OpenApiGenerateDocumentsOptions>
</PropertyGroup>
Your app starts during the build

The generator launches the app entry point against a mock server. Expensive startup, secret reads, cloud registrations and Aspire defaults may run unless you guard them.

using System.Reflection;

if (Assembly.GetEntryAssembly()?.GetName().Name != "GetDocument.Insider")
{
    builder.AddServiceDefaults();
}
  • Add the Microsoft.Extensions.ApiDescription.Server package for design-time generation.
  • YAML generation is runtime-only in .NET 10.
  • Use build output as input to OpenAPI linting and client generation.

Ask who currently commits generated OpenAPI documents. The important warning is that Program startup code runs during generation, so teams should keep startup deterministic and safe under a build agent.

Minimal APIs get built-in validation

DataAnnotations validation is now a framework feature for Minimal API parameters instead of a per-team endpoint filter.

using Microsoft.Extensions.DependencyInjection;
using System.ComponentModel.DataAnnotations;

builder.Services.AddValidation();

app.MapPost("/products", (Product product) => TypedResults.Ok(product));

public record Product(
    [Required] string Name,
    [Range(1, 1000)] int Quantity);
What it validates

Query, header and request-body values. Invalid input returns an automatic 400 with validation details and can flow through your problem-details shaping.

What bites

AddValidation() discovers types in the assembly where it is called. If endpoints live in a class library, expose an extension method there that calls it.

app.MapPost("/products", ([Required] string name) => TypedResults.Ok(name))
   .DisableValidation();
Known .NET 10 edge case

The digest flags a known .NET 10 bug: nullable value types declared directly as Minimal API parameters are not validated. Test the exact endpoint shapes you rely on.

Ask how their Minimal APIs validate today. This is a good refactor target, but warn that the multi-assembly discovery rule is silent: the endpoint can appear to work while validation never runs.

Two upgrade changes that show up as production outages

These are good changes for APIs and security, but they change runtime behaviour under real infrastructure.

Cookie auth for API endpoints

Known API endpoints now return 401 or 403 instead of redirecting to login or access denied. The marker is IApiEndpointMetadata, applied to [ApiController], JSON Minimal APIs, TypedResults endpoints and SignalR.

options.Events.OnRedirectToLogin = context =>
{
    context.Response.Redirect(context.RedirectUri);
    return Task.CompletedTask;
};
Forwarded Headers hardening

X-Forwarded-* values from unknown proxies are ignored. If TLS termination is not in KnownProxies or KnownIPNetworks, HTTPS redirects and auth can fail.

app.UseForwardedHeaders(new ForwardedHeadersOptions
{
    KnownProxies = { IPAddress.Parse("10.0.0.7") },
    KnownIPNetworks = { new IPNetwork(IPAddress.Parse("10.0.0.0"), 8) }
});
The Forwarded Headers escape hatch is not for .NET 10

The AppContext switch for ignoring unknown proxies is documented for apps targeting .NET 9 or earlier. On .NET 10, fix the trusted proxy configuration.

Ask what sits in front of their apps: ingress, app gateway, CDN, service mesh. The redirect change breaks tests; the forwarded-header change can break production. Do not let people leave without checking proxy trust lists.

The Swashbuckle question is strategic, not rhetorical

Microsoft's default path changed. Your team's decision should be explicit.

Built-in path
  • Templates use AddOpenApi() and MapOpenApi().
  • No OpenAPI UI ships in Microsoft.AspNetCore.OpenApi.
  • Visual UIs such as Swagger UI or Scalar are opt-in and should stay development-only.
Migration vocabulary
  • IDocumentFilter becomes a document transformer.
  • IOperationFilter becomes an operation transformer.
  • ISchemaFilter becomes a schema transformer.
Do not overstate the conclusion

First-party docs say Swashbuckle is no longer included by default and remains available as a community package. They do not say it is dead. The decision is whether generation, UI and filters stay in one community stack or move toward the built-in generator.

Keep this practical. Ask what custom filters they own, which UI they expose, and whether their generator consumers accept OpenAPI 3.1. Avoid claims about third-party maintenance that are not first-party verified.

Server-Sent Events get a first-party result

Use SSE when the server needs to push a one-way stream and WebSockets would be more machinery than value.

using System.Runtime.CompilerServices;
using System.Net.ServerSentEvents;

app.MapGet("/events", (CancellationToken cancellationToken) =>
{
    async IAsyncEnumerable<SseItem<string>> Stream(
        [EnumeratorCancellation] CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            yield return new SseItem<string>($"tick {DateTimeOffset.UtcNow}");
            await Task.Delay(TimeSpan.FromSeconds(1), ct);
        }
    }

    return TypedResults.ServerSentEvents(Stream(cancellationToken));
});
Cancellation is not optional

Thread the endpoint CancellationToken into the iterator with [EnumeratorCancellation]. Otherwise a disconnected client can leave work running. Also check buffering or compression middleware in front of the endpoint.

Position SSE against polling and SignalR. It is ideal for status feeds and notifications where the browser listens and the server speaks. Do not imply it replaces bidirectional messaging.

JSON Patch moves to System.Text.Json

The new package removes a common Newtonsoft dependency, but the behaviour is not a byte-for-byte clone.

using Microsoft.AspNetCore.JsonPatch.SystemTextJson;

var patchDoc = JsonSerializer.Deserialize<JsonPatchDocument<Person>>(jsonPatch);
patchDoc!.ApplyTo(person, error =>
{
    // error.AffectedObject and error.ErrorMessage are available here.
});
Why teams care

First-party benchmarks in the digest show much lower time and allocation in both application and deserialization scenarios.

What to test

No dynamic-type support. Runtime type determines patched properties. Existing patch documents need regression tests before migration.

ApplyTo mutates and does not roll back

If one operation fails, earlier operations can already be applied. Validate operations before applying and discard the target instance yourself when failure should be atomic.

Ask whether patch documents come from public clients. If yes, treat them as contract fixtures. Mention the security warning plainly: JSON Patch can still amplify memory use or patch fields users should not control.

Wiring JSON Patch into an endpoint

The package is only half the story. The shape teams actually ship is a PATCH endpoint that binds JsonPatchDocument<T> straight from the body.

[HttpPatch("{id}")]
public Results<Ok<Person>, ValidationProblem, NotFound<ProblemDetails>> Patch(
    int id, JsonPatchDocument<Person> patchDoc)
{
    var person = _repo.Get(id);
    if (person is null) return TypedResults.NotFound(new ProblemDetails());

    var errors = new Dictionary<string, string[]>();
    patchDoc.ApplyTo(person, e =>
        errors[e.AffectedObject.GetType().Name] = [e.ErrorMessage]);

    return errors.Count > 0
        ? TypedResults.ValidationProblem(errors)
        : TypedResults.Ok(person);
}
No extra registration

The parameter binds on AddControllers() alone. The Newtonsoft era needed an explicit input formatter; this does not.

Minimal APIs too

MapPatch binds the same type. Add .Accepts<JsonPatchDocument<Person>>("application/json-patch+json") so the OpenAPI document says so.

Measured on SDK 10.0.300, ASP.NET Core 10.0.12, package 10.0.12

Controller and Minimal API endpoints both patched a nested property and returned 200. The two-argument ApplyTo turns a bad path into a 400 instead of an exception.

PATCH /person/123   [{"op":"replace","path":"/address/city","value":"Munich"}]
200   {"firstName":"Holger", ... "address":{ ... "city":"Munich" ... }}

PATCH /person/123   [{"op":"replace","path":"/nope","value":"X"}]
400   {"errors":{"Person#1":["The target location specified by path
        segment 'nope' was not found."]}}
The wrong content type is a 415, not a 400

JSON Patch requires Content-Type: application/json-patch+json. Sending the same body as application/json was rejected before the action ever ran.

Content-Type: application/json-patch+json  ->  200
Content-Type: application/json             ->  415 Unsupported Media Type

This is the slide that saves a support call. The 415 arrives with no body and no model-state error, so it reads like a routing bug. Ask whose client sets the content type — hand-rolled HttpClient code and some gateways default to application/json and will fail every patch.

The error-callback overload matters: the single-argument ApplyTo throws instead, which becomes a 500 unless something catches it.

JSON Patch rules out Native AOT

It publishes, it starts, and it fails on the first request. If an API is heading for Native AOT, JSON Patch is a design decision, not a detail.

Measured: native AOT build succeeds, first PATCH returns 500

Published win-x64 with PublishAot. The compiler warned, the binary ran, and the request failed:

warning IL3053: Assembly 'Microsoft.AspNetCore.JsonPatch.SystemTextJson'
                produced AOT analysis warnings.

System.NotSupportedException: JsonTypeInfo metadata for type
'JsonPatchDocument`1[Person]' was not provided by TypeInfoResolver of type '[]'.
The usual escape hatch does not open

Source generation is the standard answer to that exception. The generator refuses this type, so the fix is unavailable — and it tells you at build time:

warning SYSLIB1030: Did not generate serialization metadata for type
                    'JsonPatchDocument<Person>'
warning SYSLIB1220: The 'JsonConverterAttribute' type
                    'JsonPatchDocumentConverterFactory' is not a converter type
                    or does not contain an accessible parameterless constructor

With the context registered, the exception came back unchanged except for the resolver name: TypeInfoResolver of type '[PatchContext]'.

Why it happens

Native AOT runs System.Text.Json with no reflection fallback. JsonPatchDocument<T> is deserialized by a converter factory the generator cannot emit metadata for.

What to do instead

Keep the JIT, or model the patch explicitly — a nullable DTO, or merge-patch semantics you control. Both are AOT-safe; neither is RFC 6902.

Ask directly whether anyone is targeting Native AOT for APIs. If nobody is, this is a thirty-second slide — say the constraint exists and move on. If somebody is, it is the most useful thing in the module, because the failure surfaces in production rather than in CI: the build is green.

Be precise about scope: this is Native AOT, not trimming in general, and not the JIT. Do not let it become "JSON Patch is broken in .NET 10".

Identity adds passkey APIs, not a general WebAuthn library

ASP.NET Core Identity now has first-party WebAuthn/FIDO2 passkey support for authentication scenarios.

builder.Services.Configure<IdentityPasskeyOptions>(options =>
{
    options.ServerDomain = "contoso.com";
});
API surface

IdentityPasskeyOptions, IPasskeyHandler<TUser>, PasskeyHandler<TUser> and SignInManager<TUser> methods for creation options, request options, attestation and assertion.

Security model

ServerDomain defaults to the host header. Set it explicitly or prove host-header validation is enforced at the edge.

Protect attestation state

The attestation state is JSON with no built-in signature or encryption. If the app manages it directly, protect it, make it single-use and verify the completed attestation belongs to the signed-in user.

Keep Blazor out of scope. The product includes APIs; the template UI story is Blazor-specific and Blazor is outside this workshop's scope. For MVC or Razor Pages teams, the workshop message is: you build the UI.

The ASPDEPR wall is an upgrade planning tool

Retargeting a legacy ASP.NET Core app to net10.0 can light up source warnings before behaviour breaks at runtime.

DiagnosticWhat changedMigration direction
ASPDEPR002WithOpenApiAddOpenApiOperationTransformer
ASPDEPR003Razor runtime compilationUse Hot Reload
ASPDEPR004 / ASPDEPR008WebHostBuilder, IWebHost, WebHostWebApplicationBuilder or generic host
ASPDEPR005Old forwarded-header network typesSystem.Net.IPNetwork and KnownIPNetworks
ASPDEPR006IActionContextAccessorIHttpContextAccessor and endpoint metadata
ASPDEPR007MVC OpenAPI analyzersRemove IncludeOpenAPIAnalyzers; prefer typed results metadata
Warnings are not the whole break list

Cookie redirect behaviour, exception-handler diagnostics and Forwarded Headers hardening are behavioural changes. The OpenAPI 3.1 shape and Microsoft.OpenApi 2.0 rewrite are cross-cutting migration work.

Use this as a triage slide. The compiler warnings are easy to inventory, but the runtime behaviour changes need targeted integration tests and deployment-environment checks.

What did not change in ASP.NET Core 10

Saying this out loud prevents the room from filling gaps with rumours.

No documented .NET 10 change
  • Rate limiting.
  • MapStaticAssets, apart from Blazor-only fingerprinting news that is out of scope here.
  • Kestrel, beyond .localhost development binding.
Also not an ASP.NET Core 10 headline
  • The SignalR section in the release notes is empty.
  • No documented HTTP/3 server feature change in ASP.NET Core 10.
  • HTTP/3 under trimming is a runtime/networking compatibility item, not this module's server feature story.
Credibility comes from boundaries

The official ASP.NET Core 10 material is concentrated in OpenAPI, Minimal APIs, auth, diagnostics and a deprecation sweep. Do not pad the workshop with unchanged areas.

Invite quick questions here, then close. If someone wants Blazor, MAUI or desktop items, park them: they are out of scope by design, and the time is budgeted elsewhere.

Lab 03 — OpenAPI-first Minimal APIs 40 min

Build the API contract you will deliberately break and migrate tomorrow.

cd labs/Day1/03-AspNetCore-OpenApi/start
dotnet run
  1. Generate and inspect the default OpenAPI 3.1 document.
  2. Serve the OpenAPI document as YAML as well as JSON.
  3. Add XML comments and response descriptions that appear in the contract.
  4. Add Minimal API validation and return problem details for invalid requests.
  5. Add a small SSE endpoint so the contract includes a streaming shape.
Tomorrow depends on this lab

The API you build here is the one you migrate to .NET 11 in Lab 08. You will hit the OpenAPI 3.1 → 3.2 breaking change yourself rather than just reading about it.

Make sure everyone keeps their Lab 03 workspace. Lab 08 assumes they have this API and its OpenAPI document, because tomorrow's lesson is the experience of watching the contract shape change under a real app.

Complex types are the EF Core 10 headline EF Core 10

EF Core 10 turns complex types from a niche modelling feature into the default choice for value objects.

Use for values

Model Address, Money, DateRange and similar types when the value belongs to an entity and has no identity of its own.

public sealed class Customer
{
    public int Id { get; set; }
    public required Address BillingAddress { get; set; }
    public required Address ShippingAddress { get; set; }
}

public readonly record struct Address(
    string Line1,
    string City,
    string Country);
Why not owned first?

Owned types are still entity types underneath. That hidden identity leaks into sharing, equality and bulk updates.

  • Same instance assigned twice can throw.
  • LINQ equality compares entity identity, not value.
  • ExecuteUpdate does not support owned types.
The workshop rule

If the type is part of its owner and you would copy it on assignment, start with a complex type. Reach for owned types only when you need entity-like behaviour.

Ask who has used owned types just to get table splitting. The key reset is vocabulary: complex type means value object; owned type means dependent entity. Demo by assigning the same address object to billing and shipping, then explain why reference-type mutability still needs care.

What EF Core 10 adds to complex types

EF 8 introduced complex types. EF Core 10 adds the missing pieces that make them usable in real models.

New in EF Core 10
  • Optional and nullable complex properties.
  • struct and readonly record struct complex values.
  • JSON mapping with ToJson().
  • Complex collections, mapped to JSON on relational providers.
  • ExecuteUpdate support for complex members.
Model shape
public class Customer
{
    public int Id { get; set; }
    public required Address Address { get; set; }
    public Address? SecondaryAddress { get; set; }
}

modelBuilder.Entity<Customer>(b =>
{
    b.ComplexProperty(c => c.Address, a => a.ToJson());
    b.ComplexProperty(c => c.SecondaryAddress, a => a.ToJson());
});
Collections have a narrower lane

On relational providers, complex collections are JSON-only. If you need a collection in its own table, stay with an owned collection or model a real entity.

Pause on optional complex values. The mental model is not just fewer tables; it is richer document-shaped modelling inside a relational row. If the room uses immutable records, show the struct line as the point where domain modelling and persistence finally line up.

Complex type gotchas that belong in the design review

Most mistakes come from treating complex types as owned types with a new method name.

Configuration traps
  • Complex types are not discovered by convention. Configure them explicitly or use the attribute.
  • Optional complex types need at least one required property, or a discriminator.
  • Nested complex-property column names changed in EF 10, so migrations may churn.
Model traps
  • No navigations from a complex type.
  • No separate table for the complex value.
  • No struct elements inside complex collections.
  • Reference-type complex values can still be shared and mutated.
modelBuilder.Entity<Place>()
    .ComplexProperty(p => p.Location, b => b.HasDiscriminator());

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };
Immutable is the safe default

Complex types have value semantics in EF, but C# reference objects are still reference objects. Prefer immutable records or readonly record structs and replace values with with expressions.

Use this as the caution slide before anyone starts migrating. Ask whether their owned types contain navigations or are mapped to their own table. Those two answers usually decide the migration before performance or aesthetics enter the discussion.

Owned → complex migration: a decision framework

Do not bulk-rewrite every OwnsOne. Classify each type by the behaviour your domain needs.

Good migration candidates
  • True value objects with no independent lifecycle.
  • Existing OwnsOne used only for table splitting or JSON.
  • Types compared by value in queries.
  • Types you want to update with ExecuteUpdate.
  • Types that should be structs or immutable records.
Keep owned, or use an entity
  • The type has a navigation to another entity.
  • The type is mapped to its own table.
  • The collection must be stored in a separate table.
  • Multiple owners must observe the same mutable instance.
Migration planning point

EF Core 10 changes complex-type column naming. If you need a zero-DDL cutover, pin existing names with HasColumnName before you change the model shape.

Turn this into a whiteboard exercise. Pick one owned type from the audience's own model and walk down the columns. The important outcome is a repeatable rule the team can apply later, not merely remembering the API name.

Named query filters fix the soft-delete plus tenant trap

EF Core 10 lets you attach multiple named global filters to one entity and disable only the one you mean.

Before EF Core 10
modelBuilder.Entity<Blog>()
    .HasQueryFilter(b => !b.IsDeleted && b.TenantId == tenantId);

var supportView = await context.Blogs
    .IgnoreQueryFilters()
    .ToListAsync();
EF Core 10
modelBuilder.Entity<Blog>()
    .HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
    .HasQueryFilter("TenantFilter", b => b.TenantId == tenantId);

var supportView = await context.Blogs
    .IgnoreQueryFilters(["SoftDeletionFilter"])
    .ToListAsync();
Disabling all filters is still one method call away

IgnoreQueryFilters() with no names disables everything. Treat named filter bypasses as security-sensitive code, especially around tenant isolation.

Ask the room who combines soft delete and tenancy today. The risk story lands better than the syntax: old support screens often turned off tenant isolation by accident. Also mention required-navigation inner joins and filter cycles as review checklist items.

Parameterized collections are the sleeper upgrade risk

EF Core 10 changes the default SQL shape for collection.Contains(x) on SQL Server.

int[] ids = [1, 2, 3];
var blogs = await context.Blogs
    .Where(b => ids.Contains(b.Id))
    .ToListAsync();
EF Core 8/9
@__ids_0='[1,2,3]'

WHERE [b].[Id] IN (
    SELECT [i].[value]
    FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i]
)
EF Core 10 default
WHERE [b].[Id] IN (@ids1, @ids2, @ids3)

Multiple scalar parameters give the query planner different information and a different plan-cache trade-off.

Upgrade action

Measure hot Contains queries after upgrade. If EF 8/9 behaviour was deliberately tuned, restore it globally with ParameterTranslationMode.Parameter, or choose per query with EF.Parameter, EF.Constant or EF.MultipleParameters.

Call this the slide people thank you for later. It is not a compile break; it is a plan-shape change. Ask whether anyone passes customer-selected ID lists, permission scopes or product catalog filters into queries.

SQL Server native json is useful, and it can surprise your migrations

On Azure SQL and SQL Server 2025 compatibility level 170 or later, EF Core 10 maps JSON columns to the native json type by default.

Before
CREATE TABLE [Blogs] (
    [Tags] nvarchar(max),
    [Details] nvarchar(max)
);
EF Core 10 with modern SQL Server
CREATE TABLE [Blogs] (
    [Tags] json NOT NULL,
    [Details] json NOT NULL
);

WHERE JSON_VALUE([b].[Details], '$.Viewers' RETURNING int) > 3
Top-three upgrade risk

An EF Core 10 migration can alter every existing nvarchar(max) JSON column to json. On large tables, that is a planned database change, not a casual migration diff.

How to pin old behaviour

Use compatibility level 160, or pin individual properties with HasColumnType("nvarchar(max)"). SQL Server also does not support DISTINCT over JSON arrays, so test query shapes that project primitive collections.

Make the distinction between Azure SQL and on-premises default configuration. UseAzureSql is enough to hit the modern behaviour; UseSqlServer defaults lower unless configured. This is a DBA coordination slide.

ExecuteUpdateAsync accepts regular C# now

The setters callback is a normal delegate in EF Core 10, so conditional updates no longer require expression-tree construction.

Old shape that breaks
Expression<Func<SetPropertyCalls<Blog>, SetPropertyCalls<Blog>>> setters =
    s => s.SetProperty(b => b.Rating, 5);

await context.Blogs.ExecuteUpdateAsync(setters);
EF Core 10
await context.Blogs.ExecuteUpdateAsync(s =>
{
    s.SetProperty(b => b.Rating, 5);

    if (rename)
    {
        s.SetProperty(b => b.Name, newName);
    }
});
The old limitations did not disappear

Bulk updates still bypass the change tracker, do not create an implicit transaction across multiple calls, are not batched together, and cannot set properties through navigations.

Frame this as ergonomics, not a new persistence model. The attendees who care are the ones with patch endpoints, admin tooling or generic repository helpers that used to build expression trees.

ExecuteUpdate can update inside JSON documents

When a JSON document is mapped as a complex type, EF Core 10 can update a member in one database statement.

Before
var blogs = await context.Blogs.ToListAsync();

foreach (var blog in blogs)
{
    blog.Details.Views++;
}

await context.SaveChangesAsync();
EF Core 10
modelBuilder.Entity<Blog>()
    .ComplexProperty(b => b.Details, d => d.ToJson());

await context.Blogs.ExecuteUpdateAsync(s =>
    s.SetProperty(
        b => b.Details.Views,
        b => b.Details.Views + 1));
UPDATE [b]
SET [Details].modify(
    '$.Views',
    JSON_VALUE([b].[Details], '$.Views' RETURNING int) + 1)
FROM [Blogs] AS [b]
Complex type requirement

This does not work for owned entity types. If JSON bulk update matters, it is one of the strongest reasons to move a value object from owned to complex.

Do not promise identical SQL across providers. The SQL shown is the SQL Server 2025 native-json shape from the digest. For SQLite or PostgreSQL, tell the room to check the provider's implementation before designing around it.

LeftJoin and RightJoin make outer joins readable

.NET 10 adds first-class LINQ operators, and EF Core 10 translates them to SQL joins.

Before
var query = context.Students
    .GroupJoin(
        context.Departments,
        s => s.DepartmentId,
        d => d.Id,
        (s, departments) => new { s, departments })
    .SelectMany(
        x => x.departments.DefaultIfEmpty(),
        (x, d) => new { x.s.Name, Department = d.Name ?? "[NONE]" });
EF Core 10 on .NET 10
var query = context.Students
    .LeftJoin(
        context.Departments,
        student => student.DepartmentId,
        department => department.Id,
        (student, department) => new
        {
            student.Name,
            Department = department.Name ?? "[NONE]"
        });
Method syntax only

C# query syntax does not get a new left-join form. RightJoin exists, but swapping operands and using LeftJoin is often clearer.

This is a relief slide. Keep it short and practical: the old pattern was hard to review, so developers avoided writing the query directly. Keep the focus on left and right outer joins only.

EF Core 10 vector search is exact search, not indexed search

SQL Server vector support is built into EF Core 10, but the scope matters for architecture decisions.

What EF Core 10 gives you
public class Blog
{
    [Column(TypeName = "vector(1536)")]
    public SqlVector<float> Embedding { get; set; }
}

var nearest = await context.Blogs
    .OrderBy(b => EF.Functions.VectorDistance(
        "cosine", b.Embedding, queryVector))
    .Take(3)
    .ToListAsync();
What it does not give you yet
  • No vector index support in EF Core 10.
  • No approximate nearest-neighbour query surface in EF Core 10.
  • Large datasets require a scan and distance calculation for each row.
  • Embedding generation is outside EF; use the AI stack for that.
Say this plainly

EF Core 10 can express exact k-nearest-neighbour ordering over SQL Server vectors. The indexed version is a Day 2 topic, not an EF Core 10 promise.

Many teams hear "vector search" and assume production RAG scale. Stop that assumption early. If they used the old external SQL Server vector package, note that EF Core 10 replaces that package-level need.

Split-query ordering gets a correctness fix

EF Core 10 makes the ordering inside split-query subqueries match the outer query, including the key.

var blogs = await context.Blogs
    .AsSplitQuery()
    .Include(b => b.Posts)
    .OrderBy(b => b.Name)
    .Take(2)
    .ToListAsync();
Before
-- Subquery ordered by name only
ORDER BY [b].[Name]

If several blogs had the same name, related rows could be matched against a non-deterministic page.

EF Core 10
-- Subquery matches the outer ordering
ORDER BY [b].[Name], [b].[Id]

The key is included, so paging and related-data loading stay aligned.

Correctness fixes can still change behaviour

No action is usually required, but audit tests around AsSplitQuery, non-unique ordering columns and Skip/Take. A workaround for the old bug may now be wrong.

Call this a silent wrong-results fix, not a performance feature. Ask whether their integration tests include duplicate ordering values. If not, their tests may never have exercised the dangerous path.

EF Core 10 logs less sensitive data and warns on raw-SQL concatenation

Two small defaults reduce common production risks without changing the SQL that executes.

Constant redaction

Inlined constants, including values forced with EF.Constant, are redacted as ? in EF logs unless sensitive-data logging is enabled.

-- Logged text in EF Core 10
WHERE [u].[Role] IN (?, ?)
Raw-SQL analyzer

A Roslyn analyzer warns when string concatenation flows into raw-SQL APIs.

var users = context.Users.FromSqlRaw(
    "SELECT * FROM Users WHERE [" + fieldName + "] IS NULL");
Custom loggers must adapt

IRelationalCommandDiagnosticsLogger implementations gained a logCommandText parameter. Use the redacted string for logs, and the real command text only for execution logic.

Keep this away from a generic security lecture. The value is operational: fewer customer identifiers in logs and earlier warnings in the raw-SQL paths that tend to bypass normal LINQ review.

Breaking changes part 1: defaults that change generated artefacts

Microsoft rates most of these low impact, but they still affect migrations, snapshots, diagnostics and deployment behaviour.

Change Why you care Mitigation
SQL Server json default Migration may alter every JSON column. Pin compatibility level or column type.
Parameterized collections Plan shape and performance can change. Choose collection translation mode globally or per query.
SQL parameter names simplified SQL snapshot tests and parameter-name parsers break; plan cache warms again after deploy. Update snapshots and avoid parsing generated names.
Complex column names Duplicate names get uniquified; nested names include the full path. Use HasColumnName where DDL churn is unacceptable.
Application Name injection Mixing EF with other SqlClient users can split connection pools. Set your own application name explicitly.
Upgrade checklist

Do not review the generated migration as boilerplate. In EF Core 10, the migration and SQL text are where several important upgrade behaviours show up first.

This is the first money slide. Walk it like an upgrade checklist. Ask which teams snapshot generated SQL in tests, which teams parse logs, and who owns database-change approval for JSON column type changes.

Breaking changes part 2: SQLite date/time and tooling are the loud ones

The only high-impact EF Core 10 breaking changes in the digest are Microsoft.Data.Sqlite date/time behaviours.

High impact: Microsoft.Data.Sqlite
  • GetDateTimeOffset without an offset now assumes UTC, not local time.
  • Writing DateTimeOffset into a REAL column converts to UTC first.
  • GetDateTime with an offset returns UTC and DateTimeKind.Utc.
AppContext.SetSwitch(
    "Microsoft.Data.Sqlite.Pre10TimeZoneHandling",
    isEnabled: true);
Medium and low, but noisy
  • EF tools require --framework for multi-targeted projects.
  • ExecuteUpdateAsync compile breaks only for expression-tree setter variables.
  • Custom IRelationalCommandDiagnosticsLogger implementations need the new log-text parameter.
  • Migration transaction behaviour returns to the EF 8 shape after an EF 9 regression.
SQLite is not just toy storage

Many production teams use SQLite in integration tests, desktop apps, mobile apps or local caches. Date/time assertions can change even if SQL Server is the production database.

Do not let SQL Server teams skip this. Ask whether their CI suite uses SQLite as a cheap relational stand-in. The switch is a temporary compatibility escape hatch, not the recommended final state.

And also: useful EF Core 10 footnotes

These are not the headline, but they are worth knowing before you close the upgrade plan.

Default constraints

SQL Server default constraints can be named explicitly, or auto-named model-wide. Turning on model-wide naming can rename every existing default constraint in the next migration.

SQLite keys

UseAutoincrement gives more control over SQLite autoincrement behaviour, including properties with value converters.

Cosmos search

Cosmos full-text and hybrid search are EF Core 10 topics, with provider-specific modelling and scoring rules.

Translations

New translations include DateOnly.DayNumber, DateOnly.ToDateTime(), string functions taking char, and several SQL Server and SQLite improvements.

Performance polish

Smaller query improvements include consecutive LIMIT optimisation, better Count over collections and reduced lazy-loading AsyncLocal usage.

Still not production-ready

Compiled models with NativeAOT precompiled queries remain experimental in EF Core 10. Do not treat them as a migration requirement.

Keep EF 11 out of the Day 1 story

Some tempting topics belong to later releases or earlier provider work. Keep this module scoped to the EF Core 10 items the team can act on today.

Use this as the close and parking-lot slide. If someone asks about a feature not shown, check whether it belongs to EF 11 or to a provider-specific release before committing. Point indexed vector questions to Day 2.

Lab 04 — EF Core 10 value objects and filters 30 min

Use SQLite for the hands-on pieces, then keep the same model for the EF Core 11 migration tomorrow.

cd labs/Day1/04-EfCore10/start
dotnet test
  1. Model an address-style value object as a complex type.
  2. Add two named query filters to the same entity.
  3. Disable one named filter in a support-style query while keeping the other active.
  4. Use ExecuteUpdateAsync with a statement lambda.
Tomorrow's continuation

Lab 09 migrates this model to EF Core 11. Keep the SQLite lab simple today so the differences tomorrow are about EF behaviour, not local infrastructure.

Watch for people reaching for owned types out of habit. Ask them whether the value has identity. If the answer is no, steer them back to complex types before they start wiring up ownership configuration.

The .NET 10 adoption checklist

Everything today, reduced to the order you should actually do it in.

#StepWhy this order
1Retarget to net10.0 with <LangVersion>13.0</LangVersion> pinnedSeparates "the runtime changed" from "the language changed". Two boring commits instead of one frightening one.
2Update the SDK on CI before anyone else noticesDeveloper machines upgrade themselves; build agents do not.
3Run the full test suiteThis is your runtime-behaviour baseline. Do it before adopting anything new.
4Upgrade EF Core to 10 with the TFMVersion-coupled. Leaving it behind loses the query fixes silently.
5Diff your OpenAPI document before and afterThe document changes shape even when your code does not. Your clients are generated from it.
6Remove the LangVersion pin and rebuildNow C# 14 arrives on its own, with its own diff and its own review.
7Treat CS9258 as an errorIt is the one warning today that can corrupt data.
8Then adopt features that earn their placeExtension members and complex types are refactors, not upgrades. Budget them separately.

If the room takes one slide home, make it this one. Offer to leave it on screen while people photograph it, and point out that steps 1–7 are mechanical: no design decisions, no architectural argument, nothing that needs a meeting.

The four changes that won't announce themselves

Compilation errors find themselves. These four compile, run, and behave differently.

Module 2

Overload resolution moves to spans. A call that bound to IEnumerable<T> can bind to ReadOnlySpan<T>, and a null argument becomes an empty span instead of throwing.

Check: APIs that offer both overloads, and null-guard tests that assert on ArgumentNullException.

Module 2

field shadows a member of the same name. Reads start returning the synthesized backing field. CS9258 warns; it does not stop you.

Check: search for members literally named field, then turn CS9258 into an error.

Module 4

The OpenAPI document changes shape. Version 3.1 by default, nullability expressed as a type array, integers carrying a string alternative.

Check: regenerate a client and diff it. Not the document — the client.

Module 5

Query parameterisation changes. The same LINQ produces different SQL, which means different plans and different cache behaviour.

Check: capture the SQL for your hottest queries before and after.

The common thread

None of these is a bug, and none of them will appear in a build log. Each one is found by comparing behaviour before and after — a test run, a document diff, a captured query. Budget an afternoon for that comparison and you will not be surprised in production.

Ask the room which of the four applies to them. It usually produces a short, specific to-do list per team, which is a much better end to the day than a feature summary.

What tomorrow does with today's work

Day 2 is not a second feature tour. It reuses what you built today.

You built

A Minimal API with an OpenAPI 3.1 document, and an EF Core 10 model using complex types and named query filters.

Tomorrow you migrate it

Both move to .NET 11 and C# 15. You hit the OpenAPI 3.1 → 3.2 change yourself rather than reading about it.

And you decide

Whether to take .NET 11 at all. Its STS window closes within a week of .NET 10's LTS window, which makes "skip it" a legitimate answer.

Tomorrow is against a release candidate

Day 2 targets .NET 11 RC 1. Anything that has not shipped is marked as such on the slide, because RC behaviour genuinely does change. Today's material is all GA and all safe to act on.

Close by asking people to leave today's lab output on disk — labs 08 and 09 migrate labs 03 and 04 directly. If anyone will not be there tomorrow, point them at the solution folders so they can do the migration themselves.