.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.
- 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
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.
| Module | Why it's here |
|---|---|
| 1 · Platform, runtime & tooling | The upgrade decision, and what the runtime does differently underneath you |
| 2 · C# 14 | Extension members, and the changes that alter behaviour silently |
| 3 · Libraries | Post-quantum cryptography, and System.Text.Json changes that alter existing behaviour |
| 4 · ASP.NET Core | OpenAPI 3.1 by default, and what it does to your generated clients |
| 5 · EF Core | Complex types, query translation changes, and one migration that will surprise you |
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.
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.
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.
| Release | Shipped | Track | Support ends | Where you stand |
|---|---|---|---|---|
| .NET 8 | Nov 2023 | LTS | 10 Nov 2026 | Maintenance — weeks left |
| .NET 9 | Nov 2024 | STS | 10 Nov 2026 | Maintenance — weeks left |
| .NET 10 | Nov 2025 | LTS | 14 Nov 2028 | Today's target |
| .NET 11 | Nov 2026 (expected) | STS | ~Nov 2028 | Tomorrow's decision |
| .NET 12 | Nov 2027 (expected) | LTS | — | No previews yet |
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.
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.
| Thing | Coupled to | If you get it wrong |
|---|---|---|
TargetFramework | net10.0 requires the .NET 10 runtime on every machine that runs it | Startup failure with a framework-not-found message |
| C# language version | Defaults to C# 14 for net10.0 | You get new language behaviour whether or not you asked for it |
| SDK | Building net10.0 needs the .NET 10 SDK, including on CI | CI breaks after developers' machines are already fine |
| EF Core | EF Core 10 requires .NET 10 and will not run on .NET Framework | Silent loss of this release's query fixes |
| ASP.NET Core | Ships with the runtime, not as an independent package version | Surprise behaviour changes arriving with the retarget |
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:
- Retarget and build. Fix only what breaks compilation.
- 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?"
- Update packages — especially EF Core, which must match the major version.
- Then adopt C# 14 features, where they earn their place.
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.
dotnet-upgrade-assistantIt 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.
Your code stops compiling. The best kind: loud, immediate, and the compiler tells you where. Budget an afternoon.
Compiles, fails to load or bind at runtime. Usually a dependency built against an older surface. Caught by running the app at all.
Compiles, runs, does something different. The expensive kind, and the reason the rest of today keeps stopping to measure.
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
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
- It creates a subdirectory and does not convert in place. Your original
app.csis 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 whatdotnet new consolegives you. - It injects a
UserSecretsIdyou 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>
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.
| Command | What it does |
|---|---|
dotnet package add | Adds a NuGet package reference. The noun-first sibling of dotnet add package. |
dotnet tool exec | Runs a tool without permanently installing it. |
dnx | The short form of the same thing — .NET's answer to npx. |
dotnet --cli-schema | Emits the entire CLI surface as JSON. |
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
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
- .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
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.
- 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.
GC.GetAllocatedBytesForCurrentThread() around a loop. Exact, cheap, and it
answers "did this allocate?" with a number rather than an opinion.
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.
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");
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.
| Feature | What it replaces | Upgrade posture |
|---|---|---|
| Extension members | Helper classes with only extension methods | Adopt deliberately; design surface area changes |
field keyword | Manual backing fields for validation and normalisation | Good refactor, but audit names first |
| First-class spans | Some explicit .AsSpan() calls and overload duplication | Audit before retargeting language version |
| Null-conditional assignment | if (x is not null) x.P = y; | Useful, with clear short-circuit semantics |
| Smaller features | String literals, verbose lambdas, generator gaps | Adopt when local readability improves |
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.
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);
}
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.
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.
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().
public static class CustomerExtensions
{
extension(Customer)
{
public static Customer Anonymous =>
new("anonymous");
}
}
Called as Customer.Anonymous.
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}";
}
}
Cheap, deterministic, side-effect free, and reads as a quality of the receiver.
Expensive, asynchronous, mutating, parameterised, or surprising if evaluated repeatedly.
Consumers cannot tell whether an extension property enumerates a sequence unless you make that obvious.
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);
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.
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.
| Rule | Practical consequence |
|---|---|
| They live in static extension containers | You still import a namespace before the members are available |
| They cannot override real members | An instance or static member declared on the type wins over an extension member |
| They obey the extended type's accessibility | No private-field access and no encapsulation bypass |
Extension properties cannot use init accessors | Do not model object construction through extension properties |
| Extension blocks are not namespaces | Members 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.
private string _name = string.Empty;
public string Name
{
get => _name;
set => _name = value.Trim();
}
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;
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
"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>
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);
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.
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);
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.
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.0Measured 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.
Measured under both language versions: an exact T[] overload beats ReadOnlySpan<T>. The silent movement is the IEnumerable<T> case.
| Overload set | Pre-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));
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.
net10.0 is safe and net8.0 is notThe 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.
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.
if (holder is not null)
{
holder.Name = Loud();
}
holder?.Name = Loud();
customer?.Orders += 1;
array?[index] = value;
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.
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<,>)}";
nameof(List<>) evaluated to "List", and nameof(Dictionary<,>) evaluated to "Dictionary".
List
Dictionary
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);
The simplified form (s, out result) => ... compiled and behaved as documented under C# 14.
params is still differentThe 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.
public partial class CustomerViewModel
{
public partial string DisplayName { get; set; }
public partial event EventHandler? Saved;
}
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; }
}
}
Partial properties and partial events both compiled and behaved as documented under C# 14.
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();
}
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.
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.
- Inventory overload sets. Search shared APIs for pairs of
IEnumerable<T>andReadOnlySpan<T>. Add tests for null arrays and arrays with values. - Promote
CS9258. Treat thefieldshadowing warning as an error before adopting field-backed properties. - Pin if you need sequencing. Retarget to
net10.0with<LangVersion>13.0</LangVersion>, validate runtime changes, then remove the pin. - Adopt extension members where they improve API shape. Prefer domain vocabulary over helper-class churn.
- Keep convenience features boring.
nameof(List<>), null-conditional assignment, and lambda modifier inference should make code easier to read, not cleverer.
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:
- Replace every manual backing field with the
fieldkeyword. - Convert the
OrderHelpersstatic class into anextensionblock, turning at least two of its methods into extension properties. - Add a static extension member that returns an empty order.
- Find the three places that can use null-conditional assignment.
- Make the
Moneystruct 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.
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.
System.Text.Json gets strict defaults, duplicate-property rejection, source-generation reference handling, and direct PipeReader deserialisation.
Certificate lookup, PEM scanning, PFX export control, async ZIP, numeric string ordering and WebSocketStream are the practical supporting cast.
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.
An attacker records encrypted traffic or stored encrypted data now, then waits until the key-establishment algorithm is weak enough to recover secrets later.
- 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.
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 family | Standard | Use | What it does not do |
|---|---|---|---|
MLKem | ML-KEM, FIPS 203 | Key encapsulation: establish a shared secret | It does not sign data |
MLDsa | ML-DSA, FIPS 204 | Digital signatures | It does not establish a shared secret |
SlhDsa | SLH-DSA, FIPS 205 | Stateless hash-based signatures | It is not uniformly available |
CompositeMLDsa | IETF composite-signature work | Migration signatures combining ML-DSA with a classical algorithm | It is not a blanket replacement for TLS negotiation |
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 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.
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 item | Size | Design implication |
|---|---|---|
| ML-KEM-768 SPKI public key | 1206 bytes | Against 32 bytes for an X25519 public key, or 64 for an uncompressed P-256 one |
| ML-KEM-768 raw encapsulation key | 1184 bytes | The FIPS 203 format, without the 22 bytes of ASN.1 wrapper — and it is the stable API |
| ML-KEM-768 ciphertext | 1088 bytes | Every encapsulation carries a visible bandwidth cost |
| ML-KEM shared secret | 32 bytes | The output is compact because it is intended for symmetric cryptography |
| ML-DSA-65 signature | 3309 bytes | Against 64 for Ed25519 — this is the number that breaks JWTs and QR codes |
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.
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;
}
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.
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.
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.
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.
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.
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.
Application code often accepted ambiguous input, ignored unknown fields, and relied on model validation to notice missing required values later.
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)!;
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.
| Property | General | Strict |
|---|---|---|
AllowDuplicateProperties | True | False |
RespectNullableAnnotations | False | True |
RespectRequiredConstructorParameters | False | True |
UnmappedMemberHandling | Skip | Disallow |
JsonSerializerOptions shared = JsonSerializerOptions.Strict;
JsonSerializerOptions local = new(JsonSerializerDefaults.Strict);
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.
{"A":1,"A":2}
public sealed record Item(int A);
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'.
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.
JsonUnmappedMemberHandling.Disallow rejects payload members that your contract does not declare.
RespectNullableAnnotations makes non-nullable reference annotations matter during serialisation and deserialisation.
RespectRequiredConstructorParameters treats non-optional constructor parameters as required input.
var options = new JsonSerializerOptions(JsonSerializerDefaults.Strict)
{
TypeInfoResolver = MyJsonContext.Default
};
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.
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; } = [];
}
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);
You adapted a pipeline to a Stream or copied buffers into an intermediate shape before deserialising.
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.
X509Certificate2Collection.FindByThumbprintcan match thumbprints using a named hash algorithm such as SHA-256.PemEncoding.FindUtf8scans ASCII/UTF-8 PEM data without first converting bytes to chars.
ExportPkcs12overloads let callers choose PFX encryption and digest algorithms.Aessupports AES Key Wrap with Padding for RFC 5649-style key wrapping scenarios.
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 change | Why you care |
|---|---|---|
| ZIP | Async APIs such as ZipFile.OpenReadAsync and ZipArchiveEntry.OpenAsync | Large archive work no longer has to block request or UI threads |
| Collections | OrderedDictionary<TKey,TValue> overloads return the entry index from TryAdd and TryGetValue | Update ordered data without a second lookup |
| Globalisation | CompareOptions.NumericOrdering | Sort file2 before file10 without custom comparers |
| WebSockets | WebSocketStream | Use stream-shaped APIs over WebSocket messages and transports |
| Diagnostics | Telemetry schema URLs, richer out-of-process Activity data and rate-limit sampling | Make 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.
- Inventory long-lived confidentiality. Identify data and protocols where harvest-now-decrypt-later is credible.
- Probe PQC support in every target environment. Put
IsSupportedin code paths and deployment checks, not just demos. - Separate stable and experimental PQC usage. Keep
SYSLIB5006suppressions narrow and reviewed. - Harden JSON boundaries first. Start with duplicate-property rejection and strict options on public or partner-facing inputs.
- Retire local helpers. Replace custom archive, ordering, PEM, thumbprint and WebSocket plumbing where the BCL now owns the behaviour.
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
- Gate PQC features with
IsSupportedand print the local support matrix. - Generate an ML-KEM key pair, encapsulate a shared secret and decapsulate it.
- Sign and verify a payload with ML-DSA, then prove a tampered payload fails.
- Harden a JSON deserializer with strict options and duplicate-property rejection.
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.
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.
- Nullable values move from
nullable: trueto type arrays. - Nullable complex types and collections can use
oneOf. $refsibling descriptions can now survive.- Numbers and dates are emitted using invariant culture.
- Exact version assertions in contract tests.
- Client generators that only understand OpenAPI 3.0.
- Diff pipelines that assume
nullableand 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.
AddOpenApi()with no configuration emits"openapi": "3.1.1".OpenApiSpecVersion.OpenApi3_0emits"openapi": "3.0.4".OpenApiSpecVersion.OpenApi3_1emits"openapi": "3.1.1".- The enum requires
using Microsoft.OpenApi;.
using Microsoft.OpenApi;
builder.Services.AddOpenApi(options =>
{
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0;
});
3.1.0The 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);
"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" }
"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 }
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.
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;
});
The generated schema no longer needs to advertise that numeric values may arrive as JSON strings.
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.
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.
{ "type": "integer" }
{ "enum": ["Low", "High"] }
using System.Text.Json.Serialization;
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
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.
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 shape | Why it matters |
|---|---|---|
OpenApiAny | JsonNode | Examples and extension values change type. |
| Concrete model types everywhere | Interfaces plus reference types | Schemas and security schemes may be inline or references. |
OpenApiSchema.Nullable | Null in schema type | Nullability is JSON Schema 2020-12 shaped. |
Microsoft.OpenApi.Models | Microsoft.OpenApi | Namespace 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.
AddSchemaTransformer runs as schemas are registered. It sees JsonTypeInfo and is right for examples, descriptions and type-level policy.
AddOperationTransformer runs per operation. It sees ApiDescription, so use it for endpoint metadata, auth exceptions and responses.
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 ASPDEPR002The 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>
<summary>,<remarks>,<param>and<returns>.- Methods, classes, members and
[AsParameters]properties. - XML docs from referenced projects.
- 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>
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.Serverpackage 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);
Query, header and request-body values. Invalid input returns an automatic 400 with validation details and can flow through your problem-details shaping.
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();
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.
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;
};
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 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.
- Templates use
AddOpenApi()andMapOpenApi(). - No OpenAPI UI ships in
Microsoft.AspNetCore.OpenApi. - Visual UIs such as Swagger UI or Scalar are opt-in and should stay development-only.
IDocumentFilterbecomes a document transformer.IOperationFilterbecomes an operation transformer.ISchemaFilterbecomes a schema transformer.
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));
});
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.
});
First-party benchmarks in the digest show much lower time and allocation in both application and deserialization scenarios.
No dynamic-type support. Runtime type determines patched properties. Existing patch documents need regression tests before migration.
ApplyTo mutates and does not roll backIf 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);
}
The parameter binds on AddControllers() alone. The Newtonsoft era needed an explicit input formatter; this does not.
MapPatch binds the same type. Add .Accepts<JsonPatchDocument<Person>>("application/json-patch+json") so the OpenAPI document says so.
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."]}}
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.
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 '[]'.
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]'.
Native AOT runs System.Text.Json with no reflection fallback. JsonPatchDocument<T> is deserialized by a converter factory the generator cannot emit metadata for.
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";
});
IdentityPasskeyOptions, IPasskeyHandler<TUser>, PasskeyHandler<TUser> and SignInManager<TUser> methods for creation options, request options, attestation and assertion.
ServerDomain defaults to the host header. Set it explicitly or prove host-header validation is enforced at the edge.
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.
| Diagnostic | What changed | Migration direction |
|---|---|---|
ASPDEPR002 | WithOpenApi | AddOpenApiOperationTransformer |
ASPDEPR003 | Razor runtime compilation | Use Hot Reload |
ASPDEPR004 / ASPDEPR008 | WebHostBuilder, IWebHost, WebHost | WebApplicationBuilder or generic host |
ASPDEPR005 | Old forwarded-header network types | System.Net.IPNetwork and KnownIPNetworks |
ASPDEPR006 | IActionContextAccessor | IHttpContextAccessor and endpoint metadata |
ASPDEPR007 | MVC OpenAPI analyzers | Remove IncludeOpenAPIAnalyzers; prefer typed results metadata |
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.
- Rate limiting.
MapStaticAssets, apart from Blazor-only fingerprinting news that is out of scope here.- Kestrel, beyond
.localhostdevelopment binding.
- 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.
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
- Generate and inspect the default OpenAPI 3.1 document.
- Serve the OpenAPI document as YAML as well as JSON.
- Add XML comments and response descriptions that appear in the contract.
- Add Minimal API validation and return problem details for invalid requests.
- Add a small SSE endpoint so the contract includes a streaming shape.
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.
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);
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.
ExecuteUpdatedoes not support owned types.
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.
- Optional and nullable complex properties.
structandreadonly record structcomplex values.- JSON mapping with
ToJson(). - Complex collections, mapped to JSON on relational providers.
ExecuteUpdatesupport for complex members.
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());
});
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.
- 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.
- 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" };
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.
- True value objects with no independent lifecycle.
- Existing
OwnsOneused 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.
- 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.
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.
modelBuilder.Entity<Blog>()
.HasQueryFilter(b => !b.IsDeleted && b.TenantId == tenantId);
var supportView = await context.Blogs
.IgnoreQueryFilters()
.ToListAsync();
modelBuilder.Entity<Blog>()
.HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
.HasQueryFilter("TenantFilter", b => b.TenantId == tenantId);
var supportView = await context.Blogs
.IgnoreQueryFilters(["SoftDeletionFilter"])
.ToListAsync();
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();
@__ids_0='[1,2,3]'
WHERE [b].[Id] IN (
SELECT [i].[value]
FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i]
)
WHERE [b].[Id] IN (@ids1, @ids2, @ids3)
Multiple scalar parameters give the query planner different information and a different plan-cache trade-off.
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.
CREATE TABLE [Blogs] (
[Tags] nvarchar(max),
[Details] nvarchar(max)
);
CREATE TABLE [Blogs] (
[Tags] json NOT NULL,
[Details] json NOT NULL
);
WHERE JSON_VALUE([b].[Details], '$.Viewers' RETURNING int) > 3
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.
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.
Expression<Func<SetPropertyCalls<Blog>, SetPropertyCalls<Blog>>> setters =
s => s.SetProperty(b => b.Rating, 5);
await context.Blogs.ExecuteUpdateAsync(setters);
await context.Blogs.ExecuteUpdateAsync(s =>
{
s.SetProperty(b => b.Rating, 5);
if (rename)
{
s.SetProperty(b => b.Name, newName);
}
});
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.
var blogs = await context.Blogs.ToListAsync();
foreach (var blog in blogs)
{
blog.Details.Views++;
}
await context.SaveChangesAsync();
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]
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.
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]" });
var query = context.Students
.LeftJoin(
context.Departments,
student => student.DepartmentId,
department => department.Id,
(student, department) => new
{
student.Name,
Department = department.Name ?? "[NONE]"
});
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.
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();
- 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.
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();
-- 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.
-- Subquery matches the outer ordering
ORDER BY [b].[Name], [b].[Id]
The key is included, so paging and related-data loading stay aligned.
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.
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 (?, ?)
A Roslyn analyzer warns when string concatenation flows into raw-SQL APIs.
var users = context.Users.FromSqlRaw(
"SELECT * FROM Users WHERE [" + fieldName + "] IS NULL");
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. |
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.
GetDateTimeOffsetwithout an offset now assumes UTC, not local time.- Writing
DateTimeOffsetinto aREALcolumn converts to UTC first. GetDateTimewith an offset returns UTC andDateTimeKind.Utc.
AppContext.SetSwitch(
"Microsoft.Data.Sqlite.Pre10TimeZoneHandling",
isEnabled: true);
- EF tools require
--frameworkfor multi-targeted projects. ExecuteUpdateAsynccompile breaks only for expression-tree setter variables.- Custom
IRelationalCommandDiagnosticsLoggerimplementations need the new log-text parameter. - Migration transaction behaviour returns to the EF 8 shape after an EF 9 regression.
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.
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.
UseAutoincrement gives more control over SQLite autoincrement behaviour, including properties with value converters.
Cosmos full-text and hybrid search are EF Core 10 topics, with provider-specific modelling and scoring rules.
New translations include DateOnly.DayNumber, DateOnly.ToDateTime(), string functions taking char, and several SQL Server and SQLite improvements.
Smaller query improvements include consecutive LIMIT optimisation, better Count over collections and reduced lazy-loading AsyncLocal usage.
Compiled models with NativeAOT precompiled queries remain experimental in EF Core 10. Do not treat them as a migration requirement.
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
- Model an address-style value object as a complex type.
- Add two named query filters to the same entity.
- Disable one named filter in a support-style query while keeping the other active.
- Use
ExecuteUpdateAsyncwith a statement lambda.
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.
| # | Step | Why this order |
|---|---|---|
| 1 | Retarget to net10.0 with <LangVersion>13.0</LangVersion> pinned | Separates "the runtime changed" from "the language changed". Two boring commits instead of one frightening one. |
| 2 | Update the SDK on CI before anyone else notices | Developer machines upgrade themselves; build agents do not. |
| 3 | Run the full test suite | This is your runtime-behaviour baseline. Do it before adopting anything new. |
| 4 | Upgrade EF Core to 10 with the TFM | Version-coupled. Leaving it behind loses the query fixes silently. |
| 5 | Diff your OpenAPI document before and after | The document changes shape even when your code does not. Your clients are generated from it. |
| 6 | Remove the LangVersion pin and rebuild | Now C# 14 arrives on its own, with its own diff and its own review. |
| 7 | Treat CS9258 as an error | It is the one warning today that can corrupt data. |
| 8 | Then adopt features that earn their place | Extension 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.
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.
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.
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.
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.
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.
A Minimal API with an OpenAPI 3.1 document, and an EF Core 10 model using complex types and named query filters.
Both move to .NET 11 and C# 15. You hit the OpenAPI 3.1 → 3.2 change yourself rather than reading about it.
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.
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.