.NET 11 RC 1 STS RC
Day 1 was the adoption story: .NET 10 is LTS and ready now. Day 2 is a decision story: whether .NET 11 is worth taking before the next LTS.
.NET 11 and C# 15 are at Release Candidate 1. General availability is expected in November 2026.
.NET 11 is STS — which since the .NET 9 change means 24 months, not eighteen. That lands it at roughly the same date as .NET 10 LTS.
Which changes create enough value, or enough risk, to justify an annual upgrade cadence in your environment?
Use the hands-on labs to learn the shape of the release, but treat preview-era blog posts and samples carefully. Several C# 15 details changed during the preview train, and we found three widely-repeated claims that do not survive contact with RC 1.
RC 1 carries a go-live licence, supported to 13 October 2026 — i.e. it is bridged until GA rather than left stranded.
Set expectations early: this is not a sales day. Ask who can realistically take an STS release in production. If the answer is "only LTS", the value is still in planning for .NET 12 and spotting migration risks before they become urgent.
Do not repeat the "eighteen months" figure even in passing — it was true until the .NET 9 announcement and is now the single most common stale fact about .NET support. The end-of-day decision slide depends on getting this right.
Runtime Async .NET 11
Runtime-native async is the headline runtime change: the async state machine moves out of
compiler-generated types and into the runtime. Your async/await
source does not change at all.
Despite what most preview-era write-ups say, targeting net11.0 is
not enough. On 11.0.100-rc.1.26425.128 the compiler still
emits ordinary state machines unless you turn the feature on explicitly.
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<!-- Required on RC 1. Without it you get the classic compiler state machine. -->
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
The difference is directly observable by reflection — count the types in your own
assembly that implement IAsyncStateMachine:
net11.0state machines: 4
Deep3 has AsyncStateMachineAttribute: True
runtime-async=onstate machines: 0
Deep3 has AsyncStateMachineAttribute: False
Preview-era posts promise dramatically cleaner stack traces. We measured the same
four-deep failing async chain on .NET 10, on default .NET 11, and on .NET 11 with runtime
async on. All three produced byte-identical traces: four frames, real
method names, no MoveNext() and no builder noise.
That is because the runtime has reconstructed async frames for years — the ugly
<LoadAsync>d__4.MoveNext() traces people remember are .NET Framework
era. If you demo this expecting a difference, you will be standing in front of two
identical screens.
Not debugging aesthetics — cost. Removing the state machine removes per-call allocation and indirection, gives the JIT a shape it can tier and inline more aggressively, and lets the shared framework libraries themselves be compiled runtime-async. This is a throughput and allocation story, and it is the reason the whole platform is moving, not a developer-experience one.
This slide is a credibility moment. Much of the room will have read that runtime async is default in .NET 11 and that it cleans up stack traces; both claims are false on RC 1 and we can prove it. Lab 05 has them count the state machine types themselves.
If asked whether it will be default at GA: likely, but say you don't know rather than guessing. The flag is the safe thing to teach either way, because it is explicit.
Raised minimum hardware requirements
.NET 11 raises baseline instruction-set requirements for x86/x64 and Arm64. That is a runtime improvement, but it is also a real deployment question.
Very old machines, emulated environments, virtualised deployment targets and some CI runners may no longer be supported. Verify every production and build target early, before the upgrade is entangled with language or library changes.
- Production VM images and base container hosts
- Self-hosted CI agents and disaster-recovery runners
- Edge, kiosk, factory-floor and long-lived appliance hardware
- Run a tiny
net11.0smoke app on each target class - Record unsupported target types explicitly
- Keep .NET 10 LTS as the safe baseline if hardware cannot move
This is a good place to ask whether anyone still has 32-bit processes or unusual CI images. The aim is not to scare them; it is to make hardware verification an early checklist item rather than a late release-blocker.
JIT and SIMD: more work reaches the hardware
The runtime work is not only async. .NET 11 also broadens the vector surface and the hardware paths the JIT can use.
| Area | What changed | How to teach it |
|---|---|---|
| Vector construction | CreateGeometricSequence | Useful for generated numeric sequences without scalar setup loops |
| Vector rearrangement | Zip, Unzip, Concat family | Show as data-shaping operations, not hand-written intrinsics |
| Arm | SVE2 support | Relevant for Arm64 server and cloud capacity planning |
| x64 | AVX-VNNI-512 | Relevant where numeric or ML-style kernels dominate |
Do not promise a universal speed-up. These APIs and codegen paths matter when your workload reaches them; measure hot paths before making them part of the upgrade case.
If the room has analytics, image processing or model-scoring code, ask for one hot loop they recognise. Otherwise keep this short and move back to the broadly visible async and tooling changes.
SDK and tooling wins worth showing
These are the small things that make a workshop feel current: faster demos, less project scaffolding, and better defaults.
dotnet run -e FeatureFlag=RuntimeAsync -e Customer=workshop
# File-based apps can now include another file.
dotnet run app.cs
#:include closes a gap in file-based apps: shared helpers no longer force
you into a project file just to keep one script readable.
- Solution filters are supported through
dotnet sln. - NativeAOT CLI and the MSBuild server are on by default.
dotnet testhas a larger surface for test workflows.- Podman multi-arch support improves container demos.
- CLI telemetry moves from Application Insights to OpenTelemetry.
Use dotnet run -e live if possible. It is tiny, visible, and prevents a
detour into shell-specific environment variable syntax on Windows versus Bash.
Runtime Async changes the compiled shape .NET 11
The useful demo is not a prettier stack trace. It is the disappearance of compiler-generated async artefacts.
net11.0runtime : .NET 11.0.0-rc.1.26425.128
result : 1
state machines: 4
<Deep1>d__0
<Deep2>d__1
<Deep3>d__2
<Main>d__3
Deep3 has AsyncStateMachineAttribute: True
runtime : .NET 11.0.0-rc.1.26425.128
result : 1
state machines: 0
Deep3 has AsyncStateMachineAttribute: False
The recognisable <Deep1>d__0 and <Main>d__3 types are present by default and absent when runtime async is enabled. That makes the change visible in reflection, decompilers and tools that inspect assemblies.
Use this after the existing Runtime Async slide. The point is to move the room from “the flag exists” to “my diagnostic tooling may see a different assembly shape”. Do not rerun the stack-trace myth here.
Can you tell whether a dependency was compiled runtime-async?
Yes, but treat it as an assembly-inspection question, not a package-version question. Look for generated state-machine types and async attributes in the assembly you actually deployed.
using System.Reflection;
using System.Runtime.CompilerServices;
static bool LooksCompilerAsync(Assembly assembly) =>
assembly.GetTypes().Any(t =>
typeof(IAsyncStateMachine).IsAssignableFrom(t));
static bool HasAsyncAttribute(MethodInfo method) =>
method.GetCustomAttribute<AsyncStateMachineAttribute>() is not null;
Auditing your own packages, plug-ins or shared libraries after retargeting to net11.0.
Whether the deployed assembly still carries the classic compiler state-machine representation.
It is not a throughput benchmark and it does not tell you whether the code path is hot.
If a library claims to have adopted runtime async, inspect the built artefact. Do not infer it from TargetFramework alone while RC 1 still requires the feature switch.
Present this as a practical governance slide for platform teams. The code is intentionally small: it gives attendees a way to check their own binary without trusting a blog post or a package description.
What runtime async means for library authors
Your public async API shape stays boring. The implementation artefacts underneath it are the part that changes.
| Concern | Guidance | Why it matters |
|---|---|---|
| Public signatures | Keep returning Task, Task<T> or ValueTask<T> as today. | Consumers should not need source changes to consume a runtime-async build. |
| Binary compatibility | Do not expose generated state-machine type names, attributes or MoveNext shapes as a contract. | Those details are exactly what runtime async is allowed to remove. |
| Multi-targeting | Be explicit about which target and build settings produced the package you ship. | net11.0 alone did not enable the measured RC 1 behaviour. |
| Tests | Assert behaviour and cancellation semantics, not internal compiler artefacts. | Representation-sensitive tests become upgrade blockers for the wrong reason. |
The safe workshop message is “explicitly opt in and verify the artefact”. If the default changes by GA, that sentence still protects teams from silently assuming the wrong binary shape.
Use a library example from the room if one comes up: retry helpers, SDK clients, data-access abstractions. The question is whether anything downstream inspects implementation details.
Profilers and diagnostics need a representation check
Runtime async is a good thing, but it can invalidate tooling assumptions that were never part of the source contract.
- Profilers that group costs by generated
MoveNextmethods - Analysers that find async code by
IAsyncStateMachine - Telemetry enrichment that demangles
<Method>d__Nnames - Coverage or mocking tools that special-case compiler-generated async types
- One failing exception trace before and after the switch
- One profiler view on a representative async hot path
- One assembly inspection for generated state-machine types
- One CI run with code coverage and static analysis enabled
If a tool relied on generated state-machine names, the runtime-async build may not look like the code it was trained to recognise. That is a tooling compatibility check, not an application bug.
This is where operations teams usually engage. Ask what they use for profiling, tracing and coverage. Avoid saying any named tool is broken unless someone in the room has measured it.
How to evaluate runtime async on your own workload
Do not turn the feature into a microbenchmark contest. Test the service shape that actually pays your cloud bill.
- Pick one async-heavy path. HTTP fan-out, database access, queue processing and SDK clients are better candidates than CPU-only loops.
- Build two artefacts from the same commit. Default
net11.0and the same project with the runtime-async feature switch. - Measure the same load. Capture throughput, latency percentiles, allocations and GC activity. Do not paste invented numbers into the deck.
- Compare diagnostics. Check traces, profiler grouping, coverage and failure triage before calling the result production-ready.
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>
Adopt when the measured gain on a real path is worth the extra RC-era verification. If the path is cold, leave it boring and spend the upgrade budget elsewhere.
Emphasise that “no invented numbers” is a discipline, not a limitation. Attendees should leave with a measurement recipe they can run next week on their own service.
AOT and trimming: treat warnings as design feedback
.NET 11 keeps pushing deployment towards smaller, earlier-validated artefacts. That only helps if teams stop treating trim and AOT warnings as noise.
Serialisers, plug-in discovery and convention-heavy frameworks need explicit tests when trimming enters the build.
Measure cold start separately from steady-state throughput. They answer different business questions.
Validate the published image, not just dotnet run. Deployment shape is part of runtime behaviour.
Use the .NET 11 evaluation to make publish settings, container images and warning policies explicit. Even teams staying on .NET 10 LTS benefit from knowing which projects are trim-hostile.
Keep this high-level unless the room already uses NativeAOT. The useful message is that deployment validation belongs in the runtime module, not as an afterthought after language features.
Should runtime changes move you off .NET 10 LTS?
The honest answer is “sometimes”. .NET 10 is the safe LTS baseline; .NET 11 is the STS decision release.
| Choose | When the evidence says | Runtime checks before you commit |
|---|---|---|
| Stay on .NET 10 | Your estate values slower upgrade cadence more than the .NET 11 runtime gains. | Keep dependency, OpenAPI and hardware discoveries in the backlog for the next LTS move. |
| Pilot .NET 11 | A low-risk service has async-heavy traffic, modern hardware and a team that can absorb RC verification. | Run hardware smoke tests, diagnostics checks and a real workload comparison. |
| Adopt selectively | New services can move faster than regulated or long-lived systems. | Define shared-library targeting rules so one pilot does not force the whole estate forward. |
Use .NET 11 RC 1 to gather evidence. Production adoption still needs the same change-control gates as any other runtime upgrade.
This is the bridge out of module 1. Do not cheerlead. Attendees came for a decision model, and “stay on LTS” is a valid outcome for many estates.
Narrowing float conversions now saturate
The highest-impact silent behaviour change we found in .NET 11. No warning, no analyzer, no compatibility switch — the same expression returns a different number.
// values returned from [MethodImpl(NoInlining)] methods so nothing is constant-folded
double d300 = Double300(); // 300.0
double dNeg = NegOne(); // -1.0
Console.WriteLine((byte)d300);
Console.WriteLine((byte)dNeg);
| Expression | .NET 10.0.12 | .NET 11 RC 1 |
|---|---|---|
(byte)300.0 | 44 | 255 |
(byte)-1.0 | 255 | 0 |
(short)70000.0 | 4464 | 32767 |
(int)1e20f | 2147483647 | 2147483647 |
(uint)-5.0 | 0 | 0 |
.NET 10 converted to a wide integer and kept the low bits. .NET 11 clamps to the destination range.
- Only the narrowing cases:
byte,sbyte,short,ushort. intanduintalready saturated in .NET 10 — that dates from .NET Core 3.0 and is unchanged.
(byte)-1.0 is the dangerous one- It does not become a different wrong number.
- It flips
255→0— every bit set to no bits set. - Anything using that as a mask, a sentinel or an alpha channel inverts.
Grep for casts from double or float to a small integer type: pixel
and colour maths, audio sample conversion, sensor and telemetry scaling, percentage-to-byte
conversions, and anything that deliberately relied on wrap-around. Values already inside the
destination range are unaffected, so only out-of-range inputs change — which is exactly
why tests with tidy fixtures will not catch it.
This is the strongest "should we take .NET 11 at all?" slide in module 1, so do not rush it. The change is almost certainly a correctness improvement — saturation is what most people expect — but the room's job is not to decide whether it is better, it is to decide whether any of their code depended on the old answer.
Ask directly: does anyone cast floating-point values to byte? In imaging,
device, telemetry or signal-processing code the answer is usually yes. That team needs a
targeted review, not a general upgrade plan.
Worth mentioning if someone asks: unchecked((byte)300.0) written as a
compile-time constant folds to 0 on both SDKs, agreeing with neither runtime
answer. That is pre-existing and not a .NET 11 change, but it does mean the constant and
variable forms of the same expression have always disagreed.
Lab 05 — Runtime Async 20 min
Make async failure boring to diagnose.
cd labs/Day2/05-RuntimeAsync/start
dotnet run
The starter project deliberately throws from deep inside a chain of async calls. Your tasks:
- Run it on default
net11.0and count the types in your own assembly that implementIAsyncStateMachine. - Add
<Features>$(Features);runtime-async=on</Features>and count again. - Capture the stack trace in both configurations and on
net10.0, and compare them honestly — you should find no difference at all. - Explain why none of the application
async/awaitsource code changed, and where the cost saving actually comes from.
You are not microbenchmarking. You are training your eye to recognise the runtime representation change in the first artefact developers actually use: the exception stack.
If the lab machine has multiple SDK roots, remind attendees to use the workshop helper
scripts rather than whichever dotnet happens to be first on PATH.
C# 15 C# 15
C# 14 let you add members to types you do not own. C# 15 turns to modelling: closed choices, exhaustive switches, and fewer ceremony patterns around collections, loops and low-level code.
| Feature | What it replaces | Status in RC 1 |
|---|---|---|
| Union types | Nullable bags, exception-as-flow, ad-hoc result hierarchies | Default for net11.0 |
closed hierarchies | Open inheritance with defensive defaults | Default for net11.0 |
| Collection expression arguments | Manual constructors before collection expressions | Default for net11.0 |
| Extension indexers | Helper methods such as At(index) | Default for net11.0 |
Labeled break/continue | Boolean flags and small goto workarounds | Default for net11.0 |
| Memory-safety work | Over-broad unsafe regions | Preview opt-in still required |
On this machine, every feature above except memory-safety compiled and ran for
net11.0 with no LangVersion setting. C# 15 is not a separate opt-in
for normal .NET 11 projects.
Connect back to Day 1 explicitly. Extension indexers are much easier to explain once the room already understands C# 14 extension blocks.
union types C# 15
A union models a value that is exactly one of a fixed set of case types. The cases do not need to share a base class or an interface.
public sealed record Cat(string Name);
public sealed record Dog(string Name);
public sealed record Bird(string Name);
public union Pet(Cat, Dog, Bird);
Pet pet = new Dog("Rex"); // implicit conversion from a case type
string message = pet switch
{
Cat cat => $"cat {cat.Name}",
Dog dog => $"dog {dog.Name}",
Bird bird => $"bird {bird.Name}"
};
union: dog Rex
The compiler knows the complete case set, so the switch is exhaustive without a default arm. That turns "did we handle every result?" from a code-review habit into a compile-time check — though only a warning-level one until you promote CS8509, which we come back to shortly.
Use this for "one of N" results: success, validation failure, not found. It avoids an
inheritance hierarchy you only created for matching, a bag of nullable properties, or expected
control flow expressed as exceptions. At runtime, unions are backed by .NET 11 support including
UnionAttribute and IUnion.
Some features in the original union proposal are not implemented in RC 1. Treat samples written for early previews as suspect unless you have compiled them on the current SDK.
Use the result-type example verbally: success, validation failure, not found. That is where unions feel immediately useful to application teams without a type-system lecture.
closed hierarchies C# 15
closed is for the other modelling case: you own the hierarchy and want shared
members or behaviour, but you still want the compiler to know the set of descendants.
public closed class Discount
{
public required string Code { get; init; }
}
public sealed class Percentage(decimal Rate) : Discount;
public sealed class FixedAmount(decimal Amount) : Discount;
string label = discount switch
{
Percentage p => $"{p.Rate:P1} off",
FixedAmount f => $"{f.Amount:C} off"
};
closed: 42.5% open
closed is not transitiveAn intermediate descendant must itself be marked closed or the compiler loses
exhaustiveness at that level. Also note the shape rules: a closed type is
implicitly abstract and cannot be combined with sealed, static or an
explicit abstract.
Draw a two-level hierarchy on the whiteboard. The non-transitive rule is the one people forget, especially when they split a base hierarchy into subfamilies later.
Union or closed hierarchy?
Both features buy exhaustive matching. Choose based on ownership and whether the cases need shared behaviour.
| Reach for | When | Example |
|---|---|---|
union | The case types are unrelated, or you do not own all of them | API result can be success, validation failure or not found |
closed | You own the hierarchy and want common members, constructors or behaviour | Domain event family, discount rule family, workflow step family |
| Neither yet | The set is not actually closed, or plugin authors must add cases | Extensibility points, external provider models |
Adding a case does not break your build. Every non-exhaustive switch
raises CS8509, and CS8509 is a warning. The project still
compiles, and the unmatched case throws
SwitchExpressionException at runtime instead — in production, on the one
input you did not think about.
Verified on RC 1: a union switch missing an arm builds with
0 Error(s) and runs happily until it meets the missing case.
<!-- Without this, "exhaustive matching" is a suggestion, not a guarantee. -->
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);CS8509</WarningsAsErrors>
</PropertyGroup>
Once CS8509 is an error, adding a case makes every stale call site fail to compile. That is not friction — that is the maintenance point, forcing each call site to make the new business decision explicitly. This one line is the difference between unions being a type-safety feature and unions being a nicer-looking runtime exception.
This slide should be interactive. Ask for one domain type from the room and classify it together. If they argue, that usually reveals the real design constraint.
Do not skip the CS8509 point — it is the single most useful thing on this slide.
Most teams will adopt unions, never set the flag, and conclude the feature does not do what
it was sold as doing. If they already run TreatWarningsAsErrors, they get this
for free; ask who does.
Collection expression arguments C# 15
Collection expressions are concise, but before C# 15 they could not pass constructor
arguments such as capacity or a comparer. with(...) fills that gap.
int[] values = [1, 2, 3];
List<int> list = [with(capacity: 4), .. values];
HashSet<string> names = [
with(StringComparer.OrdinalIgnoreCase),
"Hello",
"hello"
];
list.Count = 3
list.Capacity = 4
names.Count = 1
The set count is 1 because the case-insensitive comparer deduplicated
"Hello" and "hello". That is the headline: with(...)
genuinely reaches the constructor, so the comparer is in effect during
construction rather than applied afterwards.
The capacity is 4 — exactly what we asked for, not rounded up to a
power of two and not shrunk to the element count. We also measured the neighbouring cases:
| Expression (3 elements) | Capacity |
|---|---|
[with(capacity: 4), .. values] | 4 |
[with(capacity: 8), .. values] | 8 |
[.. values] — no arguments | 3 |
So the no-argument form gives you a list with zero slack: the very next
Add reallocates. That is the practical reason to use with(capacity:)
when you know the list will keep growing.
An earlier draft of this slide claimed the capacity came back as 6 and used it
as a "the runtime does what it likes" humility moment. We compiled it: it is 4.
The honest lesson is the opposite one — the argument is passed through precisely, which
is exactly what makes the feature worth using.
If someone asks why the no-argument case is 3 and not larger: the compiler knows
the length of the spread source, so it can size the list exactly once. Helpful for a
build-and-discard list, unhelpful for one you will append to.
extension indexers C# 15
C# 14 introduced extension blocks. C# 15 lets those blocks declare indexers too, which removes another reason to invent helper methods on types you do not own.
public static class EnumerableIndexers
{
extension(IEnumerable<int> numbers)
{
public int this[int index] => numbers.ElementAt(index);
}
}
IEnumerable<int> values = [1, 2, 3, 4];
Console.WriteLine(values[2]);
extension indexer: 3
An indexer reads like constant-time access. If the receiver is an IEnumerable<T>,
the implementation may still enumerate. Use this where the abstraction matches the cost.
Relate this to extension properties from Day 1. The design question is the same: are you improving the type's natural surface, or disguising a helper as a member?
Labeled break and continue
Nested loops often grow boolean flags or tiny goto exits. C# 15 lets
break and continue target a specific enclosing loop by label.
Customer[] customers =
[
new Customer("A", [new Order(false, "a1"), new Order(true, "a2"), new Order(false, "a3")]),
new Customer("B", [new Order(false, "b1")]),
];
outer:
foreach (var customer in customers)
{
Console.WriteLine(customer.Name);
foreach (var order in customer.Orders)
{
if (order.IsFraudulent)
continue outer;
Process(order);
}
}
A
processed a1
B
processed b1
continue outer abandons the whole remaining inner loop and advances
the outer one. Customer A had a third, perfectly good order (a3) that was never
processed. That is the correct meaning of the feature, and it is the single easiest thing to
get wrong when reaching for it: if you wanted to skip just the bad order you still want a
plain unlabelled continue.
IDE0410 flags older bool-flag and goto workarounds where the new labelled
control flow is clearer. That makes this both a language feature and a style clean-up.
Ask the room what this prints before you reveal the output. A good proportion will expect
a3 to be processed. It isn't — continue outer abandons the
whole inner loop, not just the current iteration. That miss is the entire teaching point,
and it lands much harder as a wrong prediction than as a bullet.
Keep the example boring. The feature is not about clever control flow; it is about making the intended loop target visible without extra mutable state. See FACILITATOR.md 3.10 — this slide shipped with placeholder output until we compiled it.
safe, unsafe and pointer relaxations
The memory-safety work is deliberately multi-release. It narrows where unsafe
is needed, but it does not make pointer code safe — and on RC 1 it is
not part of C# 15.
Same project, only LangVersion changed. Pointer declaration, address-of and
sizeof outside an unsafe context:
LangVersion | Result |
|---|---|
15.0 | error CS0214 × 3 — "Pointers and fixed size buffers may only be used in an unsafe context" |
preview | builds clean, 0 errors |
A feature that shipped in C# 15 would not require preview.
Treat everything below as a preview of the next language version.
- Declaring pointer types
- Taking an address with
& fixedstackallocto a pointersizeof
- Dereferencing still requires
unsafe. unsafe(expr)can be used in field initializers, constructor initializers and catch filters.safeis recognised, but has no effect on callers yet.
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
On this machine, the memory-safety features were the only area in this module that still
needed <LangVersion>preview</LangVersion> plus <AllowUnsafeBlocks>true</AllowUnsafeBlocks>.
Unions, closed hierarchies, collection arguments, extension indexers and labelled loops did not.
Do not present this as a general-purpose application feature. It matters to library, interop and performance code, and its biggest workshop value is clearing up the misconception that all C# 15 features require preview.
Be straight about the version label. Microsoft's "What's new in C# 15" page lists this
material, but the compiler rejects it at LangVersion=15.0 and the .NET 11
breaking-changes page refers to it as C# 16. When first-party sources disagree, the compiler
wins — that is the whole method of this workshop, and this is a clean example to say
so out loud.
Exhaustive does not mean a case is present
C# 15 can prove that every declared case has an arm. It cannot prove that the value you received is one of those cases.
| Construct | Absent state | How it reaches you | What happens |
|---|---|---|---|
union | default(Pet) | default(T), arrays, unassigned fields, zeroed storage | No arm matches |
closed class | null | Nullable flows, deserialisation, APIs returning nothing | No arm matches |
static string Describe(Pet pet) => pet switch
{
Cat c => $"cat {c.Name}",
Dog d => $"dog {d.Name}",
Bird b => $"bird {b.Name}",
_ => "UNINITIALISED"
};
The union switch over Cat, Dog and Bird built with 0 warnings. default(Pet) then threw SwitchExpressionException. The closed hierarchy did the same for null: build succeeded with 0 warnings, runtime threw.
Adding _ handled the absent state and produced no unreachable-arm warning, because the arm is genuinely reachable.
Ask the room what "exhaustive" proves. The useful answer is narrower than most people expect: every named case is handled, not every runtime value. This is the unifying sharp edge for both new modelling constructs, so spend time here.
What a union actually is
The syntax looks like a discriminated union. The RC 1 runtime shape is simpler: a value type wrapper around an object payload.
IsValueTypeisTrue- Base type is
ValueType - Implements
IUnion - Has
UnionAttribute - Exposes
object Value
There is no hidden non-null guarantee in the wrapper. The zero value is a legal struct value, and its Value is null.
IsValueType : True
BaseType : ValueType
iface : System.Runtime.CompilerServices.IUnion
attribute : System.Runtime.CompilerServices.UnionAttribute
member : Property System.Object Value
member : Constructor Void .ctor(Cat) / .ctor(Dog) / .ctor(Bird)
The measured union shape is a struct with an object Value property. That explains both why reference-type cases can be cheap and why value-type cases box.
Do not over-explain implementation. The presenter point is practical: if a union is a struct, it has a default value whether the business domain has one or not.
==, ToString() and identity traps
A union containing records does not behave like the record surface you are used to reaching for.
| Operation | RC 1 result | Trap |
|---|---|---|
a == b | Does not compile | Record cases do not lift their operator to the union |
a.Equals(b) | True for equal record payloads | Equality exists, just not where many developers look first |
pet.GetType() | Pet | The wrapper type is visible |
pet.ToString() | Pet | Logging does not show the record payload |
pet is Dog | True | Pattern matching sees through the wrapper |
Pet a = new Cat("Tom");
Pet b = new Cat("Tom");
// a == b // CS0019
bool same = a.Equals(b);
== produced CS0019. .Equals() returned True, and hash codes matched. For a Dog payload, GetType() and ToString() both reported Pet, while pet is Dog was True.
This slide usually changes logging guidance. Ask: if this appears in production logs as just "Pet", will your team know what happened? The fix is explicit formatting in the match, not trusting ToString.
Getting data back out of a union
The conversion into a union is implicit. The conversion out is deliberately not a cast.
Pet pet = new Dog("Rex");
var dog = (Dog)pet; // CS0030
A cast from the wrapper to a case type is not defined.
string name = pet switch
{
Dog d => d.Name,
Cat c => c.Name,
Bird b => b.Name,
_ => "unknown"
};
The pattern is the exit hatch, and it keeps the case set visible.
Value is only objectThe cast back to Dog produced CS0030. The measured reflection shape also exposed Value as System.Object, so using it moves type recovery out of the compiler's exhaustiveness checks.
Watch for attendees reaching for Value because it feels convenient. That path recreates the untyped bag the union was supposed to replace. Encourage tiny helper methods that switch explicitly if the extraction is common.
Union allocation: free until value types enter
The wrapper itself is cheap. The payload type decides whether you introduced an allocation.
| Measured case | Bytes per call | Reading |
|---|---|---|
Raw int | 0.00 | No allocation baseline |
union over int | 24.00 | The value-type case boxes |
| Raw record | 24.00 | The record allocation you already had |
union over record | 24.00 | No extra wrapper allocation observed |
GC.GetAllocatedBytesForCurrentThread()Release build, no inlining, 200,000 iterations, stored into a static field. Calling GetHashCode() during the measurement boxes the struct and contaminates the result, so the measured sample avoided that.
Use unions freely for reference-type cases on ordinary application paths. Treat unions over primitives and structs as allocation-sensitive until you have measured the specific path.
Emphasise that this is not "unions are slow". The accurate statement is narrower and more useful: object-backed value-type cases box. That is the difference between a safe domain model and an accidental hot-loop allocation.
Generic, nested and value-type unions work
The useful subset in RC 1 is broader than just a few record classes in a demo.
public union Result<T>(T, Err); // generic
public union Num(int, double); // value-type cases
public union Outer(Ok, Num); // union as a case
Result<T> lets you carry the successful value type through the model instead of falling back to object.
Num accepted both integer and double cases, including implicit construction from literals.
A union can be a case of another union, which is useful for composing smaller result families.
1 generic ok : 42
2 int case : int 42
2 dbl case : double 3.5
3 nested : num 7
The generic union, value-type union and nested union all built with 0 warnings and 0 errors on the presenter's .NET 11 RC 1 SDK.
This is the optimism slide after several sharp edges. Keep it practical: generic Result is likely the first pattern teams ask about. Then immediately remind them that value-type cases have the allocation caveat from the previous slide.
Modelling decision table
Use the feature that matches the ownership and evolution shape of the domain, not the one with the newest keyword.
| Question | Prefer union | Prefer closed |
|---|---|---|
| Do the cases share behaviour? | No; matching is the shared operation | Yes; common members, validation or helpers belong on the base |
| Do cases already exist? | Yes; you can wrap types you do not want to re-parent | No; you own the hierarchy and can design it together |
| Will external plugins add cases? | Neither is a good fit for the open boundary | Neither is a good fit for the open boundary |
| Are primitive cases involved? | Possible, but measure allocations | Usually model as named derived types instead |
| Do you need inheritance polymorphism? | No | Yes |
Closed sets are excellent inside a bounded context. At an integration boundary, partner boundary or plugin boundary, a defensive default and an extension story may be more honest than claiming the set is complete.
Run this as a room exercise. Ask for one type from their system and walk down the left column. The strongest signal is usually whether somebody outside the team needs to add a new case later.
Migrating an existing hierarchy to closed
The mechanical edit is small. The real work is checking whether the hierarchy was accidentally extensible.
public abstract class Discount
{
public required string Code { get; init; }
}
public sealed class Percentage : Discount;
public sealed class FixedAmount : Discount;
public closed class Discount
{
public required string Code { get; init; }
}
public sealed class Percentage : Discount;
public sealed class FixedAmount : Discount;
| Check | Why it matters |
|---|---|
| External derivations | A hierarchy used as an extensibility point should not become closed without a replacement extension model. |
| Switch defaults | Default arms may hide places that should now make an explicit decision for each derived type. |
| Null boundaries | closed improves case coverage, not null-state safety. |
| Warning policy | Promote CS8509 if you want stale switches to block the build. |
Reflection reported the closed base as IsAbstract=True, IsSealed=False, with System.Runtime.CompilerServices.IsClosedTypeAttribute.
The before/after is intentionally boring. That is the point: the keyword can be easy to add, but it changes the contract of the hierarchy. Ask who outside the room derives from their base classes today.
Nullability does not close the absent-state hole
Nullable reference types and exhaustive matching answer different questions. You want both.
Can this expression be null according to the annotations and flow the compiler can see?
If a value is present, did you handle every declared case in the closed set?
static string Label(Discount? discount) => discount switch
{
null => "missing discount",
Percentage p => $"{p.Rate:P1} off",
FixedAmount f => $"{f.Amount:C} off"
};
The closed-hierarchy sample built with 0 warnings and 0 errors, then a null input threw SwitchExpressionException. For unions, default(Pet) produced the equivalent absent value with Value == null.
At deserialisation, database and external API boundaries, model absence explicitly: null arm for references, _ arm for unions, or validate before switching.
Do not let this become a nullable-reference-types debate. The crisp message is: NRT helps with source-level flow; it cannot prove what an external system actually returned.
What we would adopt first
Treat C# 15 as a modelling upgrade, not a search-and-replace exercise.
| Stage | Adopt | Guardrail |
|---|---|---|
| 1 | closed for internal hierarchies you already own | Check there are no supported external derived types |
| 2 | Reference-type unions for result models and workflow outcomes | Add absent-state arms and promote CS8509 |
| 3 | Generic unions where they remove duplicate result wrappers | Keep extraction through pattern matching |
| 4 | Value-type unions on measured paths only | Budget for boxing or avoid in hot loops |
| 5 | Extension indexers and labelled loops selectively | Use only where the member shape makes cost and control flow clearer |
These slides describe .NET 11 RC 1 behaviour on SDK 11.0.100-rc.1.26425.128. Lock the measurements into your upgrade checklist and re-run them at GA before making performance or compatibility promises.
Start with one internal result model, add exhaustive switches with explicit absent-state handling, and make CS8509 fail the build. That gives value quickly without rewriting the domain.
Close the module by making adoption feel manageable. The room should leave with a sequence, not just a list of clever features. If time is short, this is the slide that turns the technical detail into a decision.
Unions stop at the expression-tree boundary
The single most important constraint for this room: a union conversion cannot appear in an expression tree, so a union cannot be projected inside an EF Core query.
public record Dog(string Name);
public record Cat(string Name);
public union Pet(Dog, Cat);
Expression<Func<Dog, Pet>> f = d => d; // implicit Dog -> Pet conversion
error CS9369: An expression tree may not contain a union conversion.
It is a compile error, not a runtime surprise — which is the good news.
Select(x => (Pet)x)and any EF Core projection into a union.- Union-returning selectors in any
IQueryableprovider. - Moq
Setupand other expression-tree APIs. - Anything built on
Expression<Func<...>>.
- Query and materialise with ordinary types.
- Convert to the union after the query, in
LINQ to Objects. - Keep unions in the domain layer, not in the persistence layer.
// Materialise first, then model.
var rows = await db.Pets
.Where(p => p.OwnerId == ownerId)
.ToListAsync();
IReadOnlyList<Pet> pets = rows
.Select(r => r.Kind == "dog" ? (Pet)new Dog(r.Name) : new Cat(r.Name))
.ToList();
EF Core 11 gets measurably better at translating LINQ, while C# 15's headline modelling type
cannot enter a translated query at all. Those are not in conflict — they tell you where the
boundary is. Unions describe domain states; your entity types describe rows.
Teams that try to make one type do both will meet CS9369 early.
If you present only one union constraint, present this one. EF Core is the module most teams come for, and this is where the two headline features of the release actually touch.
The framing that lands: this is not a gap waiting to be filled, it is a design boundary.
An expression tree has to be translatable to SQL, and a union conversion has no SQL meaning.
Expect a question about whether a future version will lift it — be honest that we do not
know, and note that the workaround costs one Select after materialisation.
A method named with vanishes from a collection expression
C# 15 collection expressions take arguments. That gives one identifier a new meaning in one position — and the element you wrote disappears without a diagnostic.
static string with(int n) => "W" + n;
List<string> a = [with(3), "a"]; // what happens here?
List<string> b = [@with(3), "a"]; // escaped
plain with(3): Count=1, Capacity=3, Items=[a]
escaped @with(3): Count=2, Capacity=2, Items=[W3, a]
Zero warnings on the version that loses the element. with(3) was
parsed as collection-expression arguments: it set the list's capacity to 3 instead of
contributing "W3". The method is never called, so its side effects never happen.
On the SDK used to build this workshop (10.0.300) the same source does not compile at all:
CS8652, the feature is in preview. So the transition we measured is
"fails to build" → "builds and silently drops an element", not "worked
correctly" → "silently wrong". A true silent regression needs an older 10.0.1xx SDK,
which we did not have to verify. Do not claim more than that.
- A method named exactly
with… - …called as the first element of a collection expression.
- Rare — but fluent builder helpers do get named
with.
- Search for
with(as a method declaration, not arecordexpression. - Escape at the call site with
@with(…). - Better: rename it. A contextual keyword is a poor method name.
Use this as the generalisable lesson rather than as a scare story. Every release that adds
syntax in a position that previously accepted an expression creates a class of identifiers whose
meaning changes. C# has done this before with var, async,
record and field — which Day 1 covered.
The durable takeaway for the room: contextual keywords are cheap for the language designers and expensive for a codebase that used the word first. If you are choosing method names today, avoid the ones C# keeps reaching for.
Lab 06 — Unions and closed hierarchies 40 min
Model one domain result two ways, then let the compiler police the future.
cd labs/Day2/06-CSharp15-Unions/start
dotnet test
Your tasks:
- Model the starter domain result as a
union. - Model the same choices as a
closedhierarchy. - Use exhaustive
switchexpressions with no default arm. - Add a new case and watch every incomplete switch fail to compile.
- Decide which model better fits the domain and write down why.
The moment you add a new case, the compiler becomes your change-impact report. Fixing each switch is the exercise.
If attendees instinctively add a default arm, stop them. The default arm throws away the most valuable part of the feature: the compiler knowing the case set.
.NET 11 libraries .NET 11
The library story is broad rather than single-theme: JSON catches up with language features, LINQ gains missing joins, compression expands, and validation finally has an async path.
System.Text.Json, LINQ joins, async validation and HTTP body compression are the most likely to show up in everyday services.
Zstandard, decimal floating point, generic complex numbers and zero-copy streams matter where libraries sit close to data movement.
X25519, AES Key Wrap changes, TLS hardening, DNS records and connection eviction are mostly adoption-by-need.
This module is intentionally selective. Do not read the list. Anchor each area to a real production pain: JSON contract control, sequence reconciliation, validation with I/O.
System.Text.Json: naming, streams and unions
System.Text.Json continues to move from "fast serializer" towards "default platform JSON stack". In .NET 11, the most teachable changes are contract control and streaming shapes.
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.PascalCase
};
string json = JsonSerializer.Serialize(new Person("Ada"), options);
STJ PascalCase: {"FirstName":"Ada"}
JsonNamingPolicy.PascalCasefor built-in PascalCase output.- Per-member naming policy overrides and type-level ignore conditions.
- NDJSON and top-level
SerializeAsyncEnumerable. - PipeWriter targets and
Utf8JsonWriter.Reset. - C# 15 union serialization through
JsonUnionTypeStructuralClassifier.
This asymmetry is the one to remember. With a plain JsonSerializerOptions,
writing a union succeeds and emits the case's own shape — no discriminator of
any kind. Reading it back throws:
Serialize -> {"Isbn":"978-0","Title":"Compilers"} // fine
Deserialize -> JsonException: JSON value type 'Object' is ambiguous for
union type 'CatalogItem' because multiple case types can use
this value type. Specify a custom type classifier to support
deserialization.
So a union written to a queue, a cache or a jsonb column looks healthy at
write time and fails at the consumer — possibly a different service, possibly weeks
later. Register the classifier on both sides:
var options = new JsonSerializerOptions();
options.TypeClassifiers.Add(new JsonUnionTypeStructuralClassifier());
It infers the case from the object's shape, so the JSON is byte-identical either way. Nothing is added to the payload — which is also why it has limits.
Give it two cases that are structurally indistinguishable and it does not fall back or
pick one. It throws NotSupportedException when the contract is built, on your
first serialize, not at some later read:
The JsonUnionTypeStructuralClassifier cannot classify union type 'Shape'
because the case type 'Circle' can never be selected uniquely. The case type
'Square' recognizes every property name recognized by 'Circle' and has no
stricter required-property or unmapped-member constraints.
Good failure mode, real design constraint: structural classification only works
when your cases differ in shape. If two cases carry the same fields, you need a
discriminator and a custom classifier. Unions over a primitive and an object
(union Payload(int, Note)) disambiguate cleanly, because JSON value types
already differ.
Point out that PascalCase is not exciting by itself. It is a good smoke test that proves the .NET 11 library surface is actually running on the machine.
The union JSON asymmetry is worth slowing down for — it is the kind of thing that passes every unit test that round-trips through one process and then breaks in integration. Ask the room where they serialize domain types today; anyone using a message bus or a JSON column has a migration question to take home. Both callouts here were measured on RC 1 in Lab 07, not taken from documentation.
LINQ FullJoin and tuple joins
LINQ gains FullJoin, plus tuple-returning Join and
GroupJoin, across Enumerable, Queryable and
AsyncEnumerable.
FullJoin: 1/0, 2/0, 3/3, 0/4
default(T)The observed output is not nullable: missing right-side integers printed as 0,
and a missing left-side integer printed as 0. If zero is a valid value in your
domain, project to a nullable or reference type before the ambiguity matters.
Reconciling two sequences where missing-on-left and missing-on-right are both business outcomes: imports, account lists, inventory snapshots.
Value-type keys or values where default(T) is also meaningful. The join is
useful; the projection needs to be explicit.
This is the best gotcha in the module because it came from the probe. Ask what value in their domain would be dangerous as a default: zero quantity, zero price, default date.
Compression and numerics
Several .NET 11 library additions are not everyday web-app features, but they matter when you are building infrastructure or numeric libraries.
- Zstandard support in
System.IO.Compression. - CRC32 validation on ZIP reads.
- IEEE 754 decimal floating point:
Decimal32,Decimal64,Decimal128. - Generic
Complex<T>. TryParsePartialfor parser-style flows.
These APIs are worth knowing because they remove third-party or custom code in specific places. They are not a reason, by themselves, for every service to jump from .NET 10 LTS.
Keep this slide short unless the room has compression or financial/numeric code in scope. The point is awareness, not a deep API walkthrough.
Async validation, crypto and networking
Validation finally gets an async path in the platform, and the lower-level networking and crypto surface continues to move forward.
AsyncValidationAttributeIAsyncValidatableObjectValidator.ValidateObjectAsyncIAsyncStartupValidator
This matters when validation needs I/O: uniqueness checks, remote lookups, policy services or startup probes that used to block or live outside the validation pipeline.
- X25519 Diffie-Hellman.
- Unpadded AES Key Wrap.
- TLS handshake hardening.
- Channel-binding validation on Unix.
DnsResolvertyped DNS record resolution.
If someone asks for exact validation signatures, defer to the lab or docs. The important architecture point is that validation no longer has to choose between blocking and being outside the standard pipeline.
Exact names matter .NET 11
The library surface is large enough that plausible names are a real risk. These three corrections belong on the workshop slide, not in presenter memory.
| Do not teach | Teach this | Why it matters |
|---|---|---|
System.Decimal128 | System.Numerics.Decimal128 | A lab with only using System; will not compile. |
X25519 | X25519DiffieHellman | The API is a Diffie-Hellman algorithm, with provider-specific variants. |
| DataAnnotations startup validation | Microsoft.Extensions.Options.IAsyncStartupValidator | This is the options validation pipeline gaining an async path. |
System.Numerics.Decimal32/64/128, System.Security.Cryptography.X25519DiffieHellman, X25519DiffieHellmanCng, X25519DiffieHellmanOpenSsl, and Microsoft.Extensions.Options.IAsyncStartupValidator are the measured names. A bare X25519 type was not present.
Open with this slide because it protects the presenter. Say this is not pedantry: these are exactly the mistakes that make a paid workshop look copied from pre-release notes rather than tested.
Zstandard is a family, not just a stream
Use zstd when you care about modern compression ratios and fast decompression, especially for repeated small shapes such as logs or JSON events.
ZstandardStream is the familiar replacement point when code already wraps a stream with Gzip, Deflate or Brotli.
ZstandardEncoder and ZstandardDecoder are the lower-level shape for libraries that manage buffers directly.
ZstandardDictionary lets many small similar payloads share learned structure rather than paying the same discovery cost each time.
The present family includes ZstandardStream, ZstandardEncoder, ZstandardDecoder, ZstandardDictionary, ZstandardCompressionOptions and ZstandardDecompressionOptions.
Do not position zstd as “always better”. Brotli still fits web static assets, Gzip still wins on ubiquity, and Deflate remains legacy-compatible. Pick zstd when the producer and consumer can both move, and when dictionaries or decompression speed change the economics.
Ask whether the room has log shipping, message batches or JSON document stores. If yes, dictionaries are the talking point. Avoid quoting ratios unless you have measured them on their payloads.
IEEE 754 decimal floating point is not decimal with a new size
Decimal32, Decimal64 and Decimal128 are for interoperability with decimal floating-point formats, not a general instruction to rewrite financial code.
- A protocol, database or file format explicitly uses IEEE 754 decimal floating point.
- You are building numeric infrastructure that needs the same type family at multiple widths.
- Cross-language fidelity matters more than matching C#'s existing
decimalhabits.
decimalalready models money correctly in your domain.doubleis acceptable and binary floating-point compatibility is expected.- You cannot prove the external decimal floating-point requirement.
using System.Numerics;
Decimal128 amount = default;
The measured names are System.Numerics.Decimal32, System.Numerics.Decimal64 and System.Numerics.Decimal128. They are not System.Decimal32, System.Decimal64 or System.Decimal128.
Do not imply these types are a financial-code silver bullet. The safest message is interoperability and numeric-library completeness. If someone asks for exact operators or rounding modes, say those belong in the lab or docs, not on this summary slide.
Async validation is a full surface, not one helper
The important change is symmetry: object, property and value validation now have async names, including try-style variants.
| Validation scope | Throwing-style name | Try-style name |
|---|---|---|
| Object | ValidateObjectAsync | TryValidateObjectAsync |
| Property | ValidatePropertyAsync | TryValidatePropertyAsync |
| Value | ValidateValueAsync | TryValidateValueAsync |
AsyncValidationAttribute is the attribute extension point for validations that cannot be answered synchronously.
IAsyncValidatableObject is the object-level counterpart for checks that involve more than one member.
The async Validator surface measured in RC 1 is ValidateObjectAsync, ValidatePropertyAsync, ValidateValueAsync, TryValidateObjectAsync, TryValidatePropertyAsync and TryValidateValueAsync.
These names exist in RC 1. Treat framework integration behaviour as preview-sensitive until you have tested it in the specific stack: MVC, minimal APIs, background workers and custom validators may adopt the surface at different speeds.
Make the architecture point: this removes the historical pressure to block on I/O or invent a parallel validation pipeline. Do not put a signature on the slide unless it has been measured.
IAsyncStartupValidator: startup checks can await
This is the options pipeline growing an async branch, not DataAnnotations growing a hosting feature.
Startup validation was naturally synchronous. Anything involving I/O tended to move into hosted services, custom bootstrapping code or blocking calls.
IAsyncStartupValidator sits beside IStartupValidator in Microsoft.Extensions.Options, so options validation can have an async path.
Microsoft.Extensions.Options.IStartupValidator and Microsoft.Extensions.Options.IAsyncStartupValidator are side by side. IAsyncStartupValidator is not a System.ComponentModel.DataAnnotations type.
Use this for things you are willing to fail fast on: external policy availability, tenant metadata, certificate material, schema reachability or configuration combinations that require a remote lookup.
Separate this from request validation. It is about making misconfiguration fail before traffic arrives. Ask which configuration mistakes today are only discovered on the first request.
Making System.Text.Json unions round-trip
The existing slide shows the asymmetry: union serialization can work while deserialization needs classification. This slide turns that into design guidance.
Structural classification is attractive when each case already has a distinct JSON shape and you do not want to add a discriminator.
Add the classifier to every producer and consumer option set. A queue or cache boundary makes one-sided registration fail late.
Two cases with the same required shape are a contract bug. Write a test for the ambiguous case before you ship the wire format.
System.Text.Json.Serialization.JsonUnionTypeStructuralClassifier exists in System.Text.Json in the .NET 11 RC 1 reference assemblies.
If your cases differ by meaning rather than shape, use a discriminator strategy instead of asking the classifier to guess. The safest JSON contract is the one a reviewer can distinguish without running code.
Do not repeat the existing serialize/deserialize output. Instead, stress deployment topology: every boundary needs the same options. Message buses and JSON columns are the failure cases to name.
System.Text.Json: contract names, streams and writer reuse
Beyond unions, the .NET 11 JSON work is about removing small adapters around naming, streaming and buffer ownership.
JsonNamingPolicy.PascalCase gives a built-in policy for contracts that intentionally use .NET-style member names.
JsonSerializer.SerializeAsyncEnumerable makes top-level async sequences a first-class shape, useful for APIs and files that stream items instead of buffering a list.
Utf8JsonWriter.Reset helps high-throughput code reuse writer instances after the destination changes.
PipeWriter-targeted JSON APIs fit servers and pipelines that already write bytes through System.IO.Pipelines.
IAsyncEnumerable<OrderEvent> events = ReadEventsAsync();
// Use JsonSerializer.SerializeAsyncEnumerable for top-level streaming output.
JsonNamingPolicy.PascalCase, JsonSerializer.SerializeAsyncEnumerable and Utf8JsonWriter.Reset are present in the .NET 11 RC 1 reference assemblies.
The slide shows the programming model, not a serialized payload. Contract details such as separators, flushing and overload selection should be checked against the specific overload used in the lab.
Frame this as deleting glue code. Do not claim NDJSON byte-for-byte behaviour unless the lab measures it. The escaped generic in the code block is intentional; raw angle brackets would break the deck.
Cryptography in .NET 11: X25519 after the PQC conversation
Day 1 covered .NET 10 PQC: ML-KEM for key encapsulation, ML-DSA and SLH-DSA for signatures, plus platform support gates. .NET 11 adds a classical key-agreement piece.
X25519 is an elliptic-curve Diffie-Hellman key agreement algorithm. It belongs in the “derive a shared secret” family, not the signature family.
It complements, rather than replaces, the PQC migration discussion. Hybrid and transition designs need clear classical and post-quantum roles.
The real .NET 11 names are System.Security.Cryptography.X25519DiffieHellman, X25519DiffieHellmanCng and X25519DiffieHellmanOpenSsl. There is no bare X25519 type in the measured reference assemblies.
Use Day 1's PQC size and support story as the before/after anchor: .NET now exposes both newer PQC primitives and a widely used classical curve API, but protocol choice still belongs to the protocol owner.
Do not redesign TLS or messaging protocols live. The useful point is naming and taxonomy: signatures, KEMs and Diffie-Hellman are not interchangeable.
DnsResolver: DNS becomes a typed library concern
Networking code often outgrows simple host-to-address lookup. .NET 11 adds a resolver surface for code that needs more explicit DNS behaviour.
- Service discovery that needs typed DNS records.
- Diagnostics that should show resolver behaviour without shelling out.
- Libraries that need consistent DNS policy across platforms.
- DNS is configuration, caching and network policy, not just parsing.
- Container, corporate and cloud resolvers may answer differently.
- Keep fallbacks and timeouts visible in application design.
System.Net.DnsResolver and DnsResolverOptions are present in System.Net.NameResolution in the .NET 11 RC 1 reference assemblies.
The type names are verified. Avoid promising exact record coverage, overload signatures or resolver policy until the specific API surface is tested against the target runtime and environment.
Use this slide for platform and SRE attendees. The win is not that every app needs custom DNS; it is that apps already doing DNS work can move away from process execution or third-party glue.
Lab 07 — Libraries in .NET 11 25 min
Connect the language and library stories in one small reconciliation flow.
cd labs/Day2/07-Libraries11/start
dotnet test
Your tasks:
- Round-trip a C# 15
unionthrough System.Text.Json. - Use PascalCase naming and verify the JSON contract.
- Use
FullJointo reconcile two sequences. - Deliberately hit the
default(T)ambiguity with integers. - Fix the projection so missing values are unambiguous.
The final code should make both closed-choice modelling and missing-side joins visible in tests. If a future maintainer adds a union case or mistakes a missing value for zero, the build should tell them.
Also know exists
HTTP body compression, connection eviction, four new zero-copy Stream types,
EqualityComparer<T>.Create and generic Random overloads are all part
of the broader .NET 11 library surface.
Use the lab to make the FullJoin warning concrete. Most attendees understand it only after they see a legitimate zero collide with a missing value.
ASP.NET Core 11 RC 1
The ASP.NET Core story is practical rather than flashy: better contracts, better validation, better observability, and one breaking OpenAPI default you should hit in a lab before it hits CI.
OpenAPI 3.2 by default, C# union result shapes, SSE described in the document, binary file response descriptions and obsolete API reflection.
HTTP QUERY, async Minimal API validation, localization, non-experimental validation attributes, and attribute-based short-circuiting.
Native OpenTelemetry tracing, Kestrel TLS handshake observability, Zstandard compression, and more accurate rate-limiting headers.
.NET 11 is Release Candidate 1, with GA expected November 2026. Treat these APIs as close to final, but keep preview-sensitive behaviour isolated until the final SDK lands.
This module is an attention peak. Keep it anchored in the room's API estate: contract generation, client generation, gateway import and validation pipelines. Avoid the Blazor material in the release notes.
OpenAPI 3.2 is now the default Breaking
.NET 10 generated OpenAPI 3.1 by default. ASP.NET Core 11 moves that default to OpenAPI 3.2.
The generated document's OpenAPI version changes. Client generators, API-management imports, governance scanners and contract tests that only understand 3.1 may reject the document or fail assertions.
We registered three OpenAPI documents in one minimal API on
Microsoft.AspNetCore.OpenApi 11.0.0-rc.1 and read back the openapi
field each one actually served:
| Registration | Emitted openapi |
|---|---|
AddOpenApi("v1") — default | 3.2.0 |
o.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1 | 3.1.2 |
o.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0 | 3.0.4 |
Note every patch number. .NET 10 emits 3.1.1; pinning .NET 11
back to “3.1” gives you 3.1.2, not 3.1.1 and never
3.1.0. If anything in your pipeline compares that string exactly, it breaks on
upgrade even when you pin. Compare on the major.minor prefix, or not at all.
- The
openapivalue in generated documents moves from 3.1 to 3.2. - SSE endpoints can be represented properly in the 3.2 document.
- Downstream tooling may lag the framework default.
Let the app generate 3.2 in a branch and run your real toolchain against it. If any required tool is not ready, pin the generated document version deliberately and track the pin as technical debt.
using Microsoft.OpenApi;
builder.Services.AddOpenApi(o =>
o.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1);
Verified to compile and emit on RC 1. It is one line — which is exactly why it should carry a comment saying when you intend to remove it.
Do not treat this as a cosmetic diff. OpenAPI documents are build artefacts, deployment inputs and external contracts.
Ask who imports OpenAPI into an API gateway or uses generated clients. Those hands identify the people who need to run Lab 08 carefully rather than just watching the demo.
The patch-number table is the part that lands. Several people in any room will have a contract
test asserting "3.1.0" somewhere, and that string has never been correct on .NET 10
either — it has been 3.1.1 since GA. The bug is already in their repo; the
upgrade just reveals it.
Union result shapes and SSE documentation C# 15
C# 15 union types are not just language trivia. Minimal APIs can return a union of result shapes, and ASP.NET Core can reflect that shape into the OpenAPI document.
public union LookupResult(Ok<CustomerDto>, NotFound, ValidationProblem);
app.MapGet("/customers/{id}", Lookup);
static async Task<LookupResult> Lookup(CustomerId id, CustomerDb db)
=> await db.FindAsync(id) is { } customer
? TypedResults.Ok(customer.ToDto())
: TypedResults.NotFound();
That ties directly back to module 2: unions make the endpoint contract explicit in code, then OpenAPI makes it visible to callers.
.NET 10 added Server-Sent Events support. OpenAPI 3.2 gives ASP.NET Core a document shape that can describe SSE endpoints instead of leaving them as prose beside the contract.
Do not over-specify exact OpenAPI output. The important teaching point is traceability from C# union to API contract, not the precise schema shape emitted by RC tooling.
Minimal APIs grow up
The endpoint layer gets a set of small but useful pieces that remove custom plumbing from production APIs.
- Async validation for Minimal APIs, building on
IAsyncValidatableObject. - Validation localization is built in.
- Validation attributes are no longer experimental.
- Endpoints can short-circuit via attribute.
- HTTP QUERY method support.
- Accurate
Retry-Afterfrom rate limiting. - HTTP/3 starts request processing earlier.
dotnet user-jwtsworks for file-based apps.
Real validation often checks a database, cache or remote policy service. Moving that path into the supported Minimal API validation model is cleaner than hiding asynchronous work behind synchronous attributes.
Frame HTTP QUERY carefully: the point is protocol support in the stack, not that every corporate proxy will immediately permit the verb. This is a good place to mention edge-device testing.
MCP template, tracing and compression
ASP.NET Core 11 also moves some previously specialist concerns into the standard SDK and hosting stack.
dotnet new list mcp
dotnet new mcpserver -n Customer.Tools
A Model Context Protocol server template in the .NET SDK is notable because MCP servers are no longer
just samples or third-party scaffolds. They become part of the normal dotnet new workflow.
- Native OpenTelemetry tracing for ASP.NET Core.
- TLS handshake observability in Kestrel.
- Zstandard response compression and request decompression.
- Build-time OpenAPI document generation can select its environment.
The workshop machine has .NET 11 RC 1 in a user-local SDK root. Use dotnet new list mcp
in the room rather than relying on memory.
Keep the MCP explanation short: a server exposes tools and context to AI clients over a standard protocol. The SDK template matters because it lowers the barrier for internal tool servers.
The upgrade changes the envelope, not the contract
The same ASP.NET Core app was built on .NET 10 and .NET 11 RC 1, then its generated OpenAPI document was diffed.
115 lines on both runtimes.
2 lines: the OpenAPI version and the server URL formatting.
Byte-identical schemas, references, required arrays, parameters and responses.
line 2 10: "openapi": "3.1.1",
11: "openapi": "3.2.0",
line 9 server URL formatting changed
This is the reassuring message for an upgrade review: ASP.NET Core 11 changed the generated document envelope in this sample, not the endpoint contract shape.
Open by contrasting this with Day 1. .NET 10 changed a lot of schema shape when moving to OpenAPI 3.1. In this measured .NET 11 sample, the visible contract body did not churn.
servers[].url lost its trailing slash
The second measured diff is easy to dismiss, but strict downstream tooling often treats URLs as strings, not intentions.
{
"net10": { "servers": [{ "url": "http://localhost:5285/" }] },
"net11": { "servers": [{ "url": "http://localhost:5035" }] }
}
- Snapshot tests compare the whole document.
- Generated clients may normalise base addresses differently.
- Gateway import rules sometimes match allowed server URLs exactly.
- Governance scanners may flag an unexpected server entry.
The two apps used different launch profiles, so the ports differ. The real behaviour change in the measured diff is the missing trailing slash on the .NET 11 server URL.
Tell the room not to over-index on localhost. Their CI document may use a staging host or gateway base path, but the same string comparison problem applies.
The integer-or-string schema is not new
A JSON Schema type array can look like a .NET 11 novelty when you first inspect the 3.2 document. It is not.
An integer property can be described as accepting either a JSON number or a JSON string that matches the integer pattern.
That reflects ASP.NET Core JSON number-handling behaviour, not a new endpoint feature.
Do not teach "type": ["integer", "string"] as an OpenAPI 3.2 or .NET 11 improvement.
Day 1 already covered why generators may widen numeric client types.
The measured .NET 10 and .NET 11 documents emitted the same Product.id schema. The property schemas were identical across the upgrade.
This is a credibility slide. It shows that we did not stop at the first surprising-looking diff. If someone asks how to remove the shape, point back to the Day 1 strict number-handling slide.
Run the OpenAPI pipeline, not just the app
The .NET 11 app can run perfectly while the generated contract breaks a build, gateway import or partner generator.
- Generate the document in CI.
- Run the exact client generators you ship from.
- Import into your API gateway or developer portal.
- Run contract diff and governance rules against the generated file.
- Do not exact-match patch digits in the
openapifield. - Normalise server URLs before comparing snapshots.
- Fail on semantic contract drift, not harmless document ordering.
- Track any 3.1 pin as temporary technical debt.
Microsoft documents OpenAPI 3.2 as the ASP.NET Core 11 default. The measured sample shows minimal contract churn. Your generator and gateway support matrix is the missing variable.
Ask for the names of the actual tools in the room's path: NSwag, Kiota, OpenAPI Generator, APIM, Kong, custom governance, partner portals. The slide is a to-do list for those owners.
Serve pinned and 3.2 documents side by side
You do not have to choose between blocking the upgrade and surprising every downstream consumer on day one.
using Microsoft.OpenApi;
builder.Services.AddOpenApi("v1", options =>
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1);
builder.Services.AddOpenApi("v32");
A pinned document for tooling that only understands the older family.
A 3.2 document for toolchain pilots, gateway trials and new consumers.
The pin once every required consumer accepts the 3.2 document.
The same .NET 11 app served a pinned v1 document as OpenAPI 3.1.2 and a default v32 document as OpenAPI 3.2.0.
Position this as migration architecture, not permanent duplication. It lets platform teams test real consumers against 3.2 while application teams continue the runtime upgrade.
HTTP QUERY is finally describable in the document
Complex search requests often want safe semantics and a body. OpenAPI 3.2 gives ASP.NET Core somewhere standard to put that operation.
using Microsoft.OpenApi;
builder.Services.AddOpenApi(options =>
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_2);
app.MapMethods("/search", ["QUERY"], (SearchRequest request) =>
SearchService.Run(request));
The OpenAPI 3.2 Path Item Object has a native query operation slot, so generated documents no longer need to hide QUERY as a vendor extension.
Clients, proxies, gateways and security devices may not allow the method yet. Protocol support in the document is not the same as end-to-end estate support.
The API names and behaviour on this slide come from Microsoft Learn for ASP.NET Core 11. Treat it as a candidate to validate against the audience's own gateway path.
This is a good architecture discussion, not a blanket recommendation. If they already use POST for complex searches because infrastructure blocks unknown verbs, that may remain the pragmatic answer.
OpenAPI 3.2 makes SSE payloads part of the contract
Day 1 introduced first-party Server-Sent Events. ASP.NET Core 11 adds the missing contract story for streamed event payloads.
app.MapGet("/todos/stream", (CancellationToken ct) =>
TypedResults.ServerSentEvents(GetTodosAsync(ct)));
static async IAsyncEnumerable<SseItem<Todo>> GetTodosAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var todo in Todos.All)
{
yield return new SseItem<Todo>(todo)
{
EventId = todo.Id.ToString()
};
await Task.Delay(1000, ct);
}
}
Microsoft's ASP.NET Core 11 notes state that returning IAsyncEnumerable<SseItem<T>> directly is serialized as JSON. Use TypedResults.ServerSentEvents.
In a 3.2 document, ASP.NET Core describes the per-event payload with an itemSchema for text/event-stream.
Connect this to generated clients carefully. Some generators may still ignore the 3.2 SSE shape, but at least the contract can now carry the payload type accurately.
Async validation belongs in the validation pipeline
Minimal APIs already got built-in validation in .NET 10. ASP.NET Core 11 closes the gap for rules that have to await I/O.
public sealed class UniqueEmailAttribute : AsyncValidationAttribute
{
protected override ValidationResult? IsValid(
object? value, ValidationContext context) =>
throw new InvalidOperationException("Use async validation.");
protected override async Task<ValidationResult?> IsValidAsync(
object? value,
ValidationContext context,
CancellationToken cancellationToken)
{
var users = context.GetRequiredService<IUserService>();
return value is string email &&
await users.EmailExistsAsync(email, cancellationToken)
? new ValidationResult("Email already exists.")
: ValidationResult.Success;
}
}
Uniqueness checks, policy services, reference-data lookups and other validation rules that already need asynchronous work.
If a validator is async-only, throw from the synchronous path so older validation APIs cannot silently skip the real rule.
Microsoft Learn documents AsyncValidationAttribute, IAsyncValidatableObject and concurrent execution where possible in Minimal API validation.
Ask whether any validation filters currently call services synchronously or block on tasks. Those are the migration candidates, not simple required/range attributes.
Authentication gets more testable and more bound to TLS
ASP.NET Core 11 authentication changes are operational: fewer clock hacks in tests, and stronger binding between authenticated requests and TLS.
ASP.NET Core Identity uses TimeProvider for time-related operations, making token expiry, lockout and security-stamp tests deterministic.
services.AddSingleton<TimeProvider>(fakeTimeProvider);
services.AddIdentity<IdentityUser, IdentityRole>();
Apps can read a TLS channel-binding token from ITlsConnectionFeature, and Negotiate authentication on Kestrel uses TLS endpoint channel binding for HTTPS.
var tls = context.Features.Get<ITlsConnectionFeature>();
if (tls?.TryGetChannelBindingBytes(
ChannelBindingKind.Endpoint,
out ReadOnlyMemory<byte> cbt) == true)
{
// Compare cbt with the authenticated token.
}
Kestrel, IIS and HTTP.sys expose channel binding differently. Test the exact front-door and authentication scheme you run in production.
Keep this grounded in real-world scenarios: Kerberos/NTLM, reverse proxies and test suites for account lockout. Do not turn it into a general WebAuthn/passkey lecture.
Rate limits, compression and caches get sharper edges
These are not headline APIs, but they are exactly where production traffic, clients and intermediaries meet your app.
FixedWindowRateLimiter reports a more accurate RetryAfter metadata value for the next window boundary.
ASP.NET Core supports Zstandard for response compression and request decompression, and enables zstd support by default in the middleware.
Response compression now emits Vary: Accept-Encoding whenever compression is enabled, even if a specific response is not compressed.
builder.Services.AddResponseCompression();
builder.Services.AddRequestDecompression();
builder.Services.Configure<ZstandardCompressionProviderOptions>(options =>
{
options.CompressionOptions = new ZstandardCompressionOptions
{
Quality = 6
};
});
Validate these changes with real clients and intermediaries: retry libraries, CDNs, API gateways, decompression limits and cache key behaviour.
This slide is for platform and operations people. Ask who owns CDN and gateway configuration; they often are not the same team that retargets the app.
A pragmatic ASP.NET Core 11 upgrade playbook
Treat ASP.NET Core 11 as a contract and operations upgrade first. Adopt new endpoint features second.
- Save the generated OpenAPI document from the .NET 10 build.
- Record which tools consume it and who owns each one.
- List endpoints that stream, return files, or use multiple content types.
- Capture current rate-limit, compression and cache behaviour.
- Diff the generated document and classify envelope versus contract changes.
- Run generators, gateway imports and governance scanners before merging.
- Decide whether to serve a pinned document alongside the 3.2 default.
- Only then adopt QUERY, async validators, SSE item schemas or new auth hardening.
.NET 11 is RC 1 in this workshop. For behaviours not measured here, keep the source as Microsoft Learn plus an environment-specific validation task before GA.
End by making this actionable. The OpenAPI artefact is the first thing they should preserve when trying the migration in their own repository.
Lab 08 — upgrade the API 25 min
This is Day 1's Lab 03 again: the same API, retargeted from net10.0 to net11.0.
cd labs/Day2/08-AspNet11-Migration/start
dotnet build
Your tasks:
- Retarget the API project to
net11.0and build it with the RC 1 SDK. - Generate the OpenAPI document before and after the retarget.
- Diff the two documents and discover the 3.1 to 3.2 change yourself.
- Convert one endpoint to return a C# 15 union of result shapes.
- Add an async validator and watch how the endpoint behaviour changes.
The point is not to memorise OpenAPI 3.2. The point is to see exactly which of your tools notices the new document version, and whether it fails at build time, deployment time or client-generation time.
Let people run the diff before explaining the answer. It is more memorable when their own contract test or snapshot assertion fails first.
EF Core 11 Top interest
This is the module most teams come for. The through-line is simple: EF Core 10 made complex types the value-object direction; EF Core 11 makes that direction much more complete.
Complex types now work with more inheritance and provider scenarios, and can participate in keys and indexes.
Better SQL for joins, FullJoin, GroupBy work, no-op cast stripping and MaxBy/MinBy.
Migrations get several small features that matter because teams run them every week.
Open by connecting to the room's own data layer. This is where to slow down and invite data-layer questions; do not rush to the language material.
Complex types become complete
Complex types were introduced as the replacement direction for owned-entity value-object modelling. EF Core 11 is where they start to feel genuinely complete.
- TPT and TPC inheritance support.
- Complex types on Cosmos.
- Lambda chaining configuration.
- Keys can use complex type properties.
- Indexes can be defined on complex type properties.
- Value objects become more useful in real relational models.
modelBuilder.Entity<Customer>()
.ComplexProperty(c => c.Address);
modelBuilder.Entity<Customer>()
.HasIndex(c => c.Address.Postcode);
If you avoided complex types because you needed indexes, inheritance support or provider coverage, EF Core 11 removes several of those objections. That does not mean rewrite every owned type tomorrow; it means new value objects should be modelled against the new baseline.
The exact fluent API shape should be checked in the lab solution. Use the code as a modelling sketch, not as a promise that every provider supports every combination identically.
Query translation keeps getting less surprising
Most EF Core query work is not a new headline API. It is the generated SQL getting closer to what you would have written by hand.
| Area | What changes | Why you care |
|---|---|---|
| Joins | Better SQL for to-one joins; FullJoin | Cleaner plans and parity with LINQ's new full outer join shape |
| Grouping | GroupBy enhancements | More server-side work, fewer client-side surprises |
| Clean-up | No-op CAST stripping | Less noisy SQL, fewer avoidable plan differences |
| Extrema | MaxBy and MinBy translation | Use the LINQ you meant, not an awkward order-and-first rewrite |
| JSON | EF.Functions.JsonPathExists() | Provider-side JSON predicates where supported |
var newest = db.Orders
.Where(o => o.CustomerId == customerId)
.MaxBy(o => o.CreatedAt);
Use this slide to reinforce a habit: after an EF upgrade, inspect the SQL for important queries. The improvements are often visible before they show up in a benchmark.
SQL Server vector, JSON and temporal work
EF Core 11 adds several SQL Server features that are strategically important, even though the hands-on labs stay on SQLite so everyone can run them locally.
The vector and JSON-index material needs Azure SQL or SQL Server 2025. Treat this as a guided walkthrough; the lab uses SQLite to keep the room unblocked.
VECTOR_SEARCH()support.- Vector indexes.
- Vector properties are not loaded by default.
That default is right: embeddings are large and usually only needed for search or scoring paths.
- Full-text search improvements.
JSON_CONTAINSand JSON indexes.- Temporal period properties mapped to CLR properties.
Be clear that vector support is not a reason to move a simple app to SQL Server 2025. It is relevant for teams already building retrieval or similarity features in the database.
Migrations get workflow features
This is not glamorous, but it is the part data teams feel every sprint.
- Create and apply a migration in a single step.
- Exclude foreign-key constraints when generating migrations.
- Use
-NoBuildfrom Package Manager Console.
- The latest migration ID is recorded in the snapshot.
- A
dotnet efconfiguration file can hold defaults. - Wildcard context support helps multi-context solutions.
- SQLite adds ordering in string aggregation and
UInt128.
dotnet new tool-manifest
dotnet tool install dotnet-ef
dotnet ef --help
dotnet-ef is not installed globally on this machine. Labs pin it through a local tool manifest
so Day 1 and Day 2 do not fight over one global version.
Do not invent the exact single-step command if the installed EF tool is not available during presentation. The lab should let attendees discover the concrete syntax from the pinned tool version.
Where the new query operators actually live EF Core 11
The most important correction is vocabulary: these are LINQ operators. EF Core 11 makes more of them database-shaped.
System.Linq.Queryable.FullJoinSystem.Linq.Queryable.LeftJoinSystem.Linq.Queryable.RightJoinSystem.Linq.Queryable.MaxBy/MinBy
EntityFrameworkQueryableExtensionsRelationalQueryableExtensions- An EF-specific namespace import
- A new provider API you call directly
Reflection over the RC 1 assemblies found FullJoin, LeftJoin, RightJoin, MaxBy and MinBy on Queryable and Enumerable, and found all five absent from EF Core's extension classes. EF Core 11's contribution is translation, not API ownership.
Counting public static overloads across the .NET 10.0.8 and .NET 11 RC 1 reference assemblies sharpens it further:
| Method | .NET 10 Enumerable | .NET 11 Enumerable | .NET 10 Queryable | .NET 11 Queryable |
|---|---|---|---|---|
FullJoin | 0 | 2 | 0 | 2 |
LeftJoin | 2 | 3 | 2 | 3 |
RightJoin | 2 | 3 | 2 | 3 |
MaxBy | 2 | 2 | 3 | 3 |
MinBy | 2 | 2 | 3 | 3 |
FullJoin is the only genuinely new method. LeftJoin and
RightJoin already shipped in .NET 10 and gain one overload each.
MaxBy and MinBy did not change at all — their
entire .NET 11 story is that EF Core learned to translate them.
If you call them "new EF APIs", attendees will search the EF extension classes and think the deck is wrong. Say "LINQ operators that EF Core 11 can translate".
Use this slide to reset the language before showing any code. It prevents a credibility problem later, especially with senior developers who know where EF extension methods normally live.
Full outer joins: now readable LINQ, when the engine can run it
FullJoin removes the hand-written union of left and right joins, but the generated SQL still has to be valid for your database.
var query = db.Customers.FullJoin(
db.Orders,
customer => customer.Id,
order => order.CustomerId,
(customer, order) => new
{
Name = customer == null ? "[no customer]" : customer.Name,
Total = order == null ? null : order.Total
});
SQL> SELECT "c"."Name", "o"."Total"
FROM "Customers" AS "c"
FULL JOIN "Orders" AS "o" ON "c"."Id" = "o"."CustomerId"
executed OK, 2 rows
This ran on SQLite because modern SQLite supports FULL JOIN. SQLite only gained that support in 3.39, so older engines or providers can still reject the SQL EF generates.
Do not turn this into "SQLite supports every EF Core 11 join feature". The measured point is narrower: EF generated FULL JOIN and this local SQLite engine executed it. For live demos, check the deployed engine version first.
MaxBy and MinBy: prove the translation
MaxBy is an immediate operator, so the result alone tells you nothing about whether the database did the work.
var newest = db.Orders
.Where(o => o.CustomerId == 1)
.MaxBy(o => o.CreatedAt);
var newest = db.Orders
.Where(o => o.CustomerId == 1)
.OrderByDescending(o => o.CreatedAt)
.FirstOrDefault();
SQL> SELECT "o"."Id", "o"."CreatedAt", "o"."CustomerId", "o"."Total"
FROM "Orders" AS "o"
WHERE "o"."CustomerId" = 1
ORDER BY "o"."CreatedAt" DESC
LIMIT 1
result: Id=2
One row leaves the database. This is not client evaluation dressed up as LINQ.
When demoing, show the log output before showing the returned entity. The returned entity looks identical whether EF translated or pulled rows into memory, so the SQL is the only useful proof.
How to see the SQL when ToQueryString() cannot help
Use ToQueryString() for queryables. Use command logging for operators that execute immediately.
IQueryable<Order> query = db.Orders
.Where(o => o.CustomerId == 1);
Console.WriteLine(query.ToQueryString());
Good for a query you still hold as IQueryable<T>.
optionsBuilder.LogTo(
Console.WriteLine,
[RelationalEventId.CommandExecuted]);
Use this when the LINQ call returns an entity, scalar or aggregate immediately.
The measured MaxBy finding used LogTo with RelationalEventId.CommandExecuted, because MaxBy returns the row, not an IQueryable<T> you can inspect with ToQueryString().
For important queries, capture SQL before and after the EF Core 11 upgrade. The review is not "does it compile?", it is "did the query shape become what we expect?".
Point out that LogTo belongs in a test or demo configuration, not copied blindly into production with console logging. The key is the event filter: without it, EF logs too much noise for a workshop.
Client evaluation in 2026: fail loudly, then test the escape hatches
Modern EF Core generally refuses to translate unsafe query predicates. The remaining risks are explicit boundaries and projections that hide work.
A non-translatable predicate in the database part of the query throws instead of silently filtering the table in memory.
Top-level projection can still run client code after the database returns the selected values.
AsEnumerable(), ToList() and similar calls deliberately cross from database query to in-memory LINQ.
var rows = await db.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderRow(o.Id, FormatCurrency(o.Total)))
.ToListAsync();
For hot queries, run provider-backed integration tests that capture commands and assert the query shape, command count and row limits. A general warning switch will not catch an intentional AsEnumerable() placed too early.
Use this slide to avoid a false binary. "No client evaluation" is not precise enough. The useful practice is to identify where the query crosses into memory and make that crossing visible in tests.
Complex types, part 2: choose the storage shape before the API
EF Core 11 broadens complex-type modelling, but the database shape still decides what you can index, query and migrate safely.
| Model need | Prefer | Reason |
|---|---|---|
| Single value object, queried by individual members | Complex type mapped to columns | Members can participate in normal relational predicates and indexes. |
| Document-shaped value, usually loaded as a whole | Complex type mapped to JSON | Keeps ownership in one row, but provider JSON query support matters. |
| Collection of values in a relational row | Complex collection mapped to JSON | Good for contained lists, not for independent lifecycle or joins. |
| Collection needing joins, FKs or separate permissions | Owned collection or real entity | A table is still the right abstraction. |
modelBuilder.Entity<Customer>(customer =>
{
customer.ComplexProperty(c => c.BillingAddress, address =>
{
address.Property(a => a.Postcode).HasMaxLength(12);
});
customer.HasIndex(c => c.BillingAddress.Postcode);
});
EF Core 11 documents expanded complex-type support, including indexes over complex members. Treat the exact migration and provider behaviour as something to confirm on the audience's own provider before a modelling rewrite.
Do not repeat the earlier overview slide. This is the design-review slide: ask whether the value object must be filtered, indexed, joined, or audited independently. Those answers choose columns, JSON, owned type or entity.
JSON columns: queryable, but never provider-neutral
EF Core keeps improving JSON translation, especially for SQL Server, but JSON is where provider differences show up fastest.
- Contained value objects that move with the owner.
- Occasional predicates over well-known paths.
- Document fields that do not need foreign keys.
- Frequent joins into the nested values.
- Provider-independent query expectations.
- Fields requiring relational constraints or high-selectivity indexes everywhere.
var candidates = db.Products
.Where(p => EF.Functions.JsonPathExists(
p.SearchDocument,
"$.tags[*] ? (@ == \"priority\")"));
The Day 2 deck already flags EF.Functions.JsonPathExists() and SQL Server JSON work as EF Core 11 topics. Before promising behaviour in a workshop, run the exact query on the exact provider and engine version.
If the room uses SQL Server, connect this to JSON indexes and native JSON support from the earlier walkthrough. If they use PostgreSQL or SQLite, do not translate the SQL Server story by analogy; ask for provider docs and a measured query.
Migrations in CI/CD: choose the artefact, not just the command
The migration workflow question is operational: who reviews the SQL, who runs it, and which binary produced it?
Ship a single executable migration runner for controlled deployment environments.
dotnet ef migrations bundle `
--configuration Release `
--self-contained false `
--output .\artifacts\migrate.exe
Give DBAs and release gates an artefact they can inspect before applying.
dotnet ef migrations script `
--idempotent `
--no-build `
--output .\artifacts\migrate.sql
Best for local development and throwaway environments, rarely the right production control point.
dotnet ef database update `
--no-build
--no-build is a pipeline sharpenerBuild once, test that build, then generate migration artefacts from the same output. It avoids hiding a second compilation inside your database-deployment step.
Ask how production schema changes happen today. If the answer is "the app applies them on startup", keep the discussion on ownership and rollback, not on syntax. The command is less important than the artefact and approval path.
Upgrade checklist: EF Core 10 model to EF Core 11
Treat EF Core 11 as a query-and-migration review, not a package bump.
- Upgrade EF packages and the local
dotnet-eftool together. Keep runtime and design-time versions aligned. - Regenerate a migration and read it. Complex-member indexes, provider defaults and snapshot changes show up there first.
- Capture SQL for hot queries. Focus on joins, extrema, grouping, JSON predicates and provider-specific functions.
- Run the same tests on the real provider. SQLite is an excellent lab provider, not proof that SQL Server or PostgreSQL SQL is identical.
- Retest client-visible ordering and paging. Query improvements can still change plans, null handling or result order when previous code relied on accidental behaviour.
EF Core 11 is still at RC 1 in this workshop. Keep generated SQL and migration artefacts under review until GA, especially for provider-specific features.
This is the slide to leave on screen before the lab. Emphasise "read the migration" and "capture SQL" as two separate gates. Green tests alone do not tell you whether a hot query became better or worse.
Performance: what changed, and what did not
EF Core 11 query translation improvements help, but the old performance levers are still the first line of defence.
| Lever | Use when | EF Core 11 reading |
|---|---|---|
AsNoTracking() | You read data and will not save it through the same context. | Still one of the highest-signal review items for read paths. |
| Projection | You need a DTO, not the whole entity graph. | Still beats loading columns or navigations you do not use. |
| Compiled queries | The same query shape runs very often with different parameters. | Still useful for stable hot paths; not a substitute for good SQL. |
| Batching | You save multiple changes and the provider can batch commands. | Still provider-tuned; measure rather than copying one batch size everywhere. |
| SQL inspection | A LINQ change or EF upgrade affects an important path. | More important now, because EF 11 translates more shapes successfully. |
static readonly Func<OrdersContext, int, Task<Order?>> LatestOrder =
EF.CompileAsyncQuery((OrdersContext db, int customerId) =>
db.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.FirstOrDefault());
For a real production workload, compare the generated SQL, command count, returned row count and allocation profile. A translator improvement can be hidden by tracking, over-including, or a missing database index.
Keep this grounded. People want a magic "EF 11 is faster" claim; do not give it. Give them the repeatable performance review pattern instead.
Provider reality check: do not demo SQL your engine cannot run
EF Core is a common abstraction over providers that deliberately expose different database capabilities.
| Feature area | Demo posture | Risk to call out |
|---|---|---|
FullJoin | Measured on SQLite RC 1 setup. | Requires an engine/provider that accepts FULL JOIN; older SQLite engines do not. |
MaxBy / MinBy | Measured on SQLite with server-side ordering and limit. | Immediate operators need logging to prove translation. |
| SQL Server vectors | Walkthrough unless SQL Server 2025 or Azure SQL is available. | Vector indexes and search functions are provider features, not general EF promises. |
| JSON predicates and indexes | Run on the audience's own provider before teaching as reusable. | Syntax, index support and path semantics vary sharply. |
| Temporal period mapping | SQL Server-specific discussion. | Do not imply SQLite or PostgreSQL have the same temporal model through EF. |
Before delivering this, run every provider-sensitive query on the same database engine family the audience uses. If you cannot, label it as a walkthrough and keep the live lab on measured SQLite material.
Use this as the closing credibility slide. It is better to say "not measured on your provider yet" than to let a live demo fail because a feature was true for a different engine.
Explicit Property() on a collection stops working
Working EF Core 10 code that compiles unchanged on EF Core 11 and then throws at query time. It targets exactly the teams who write explicit entity configuration — which is most enterprises.
public class Team
{
public int Id { get; set; }
public List<int> Scores { get; set; } = new();
}
protected override void OnModelCreating(ModelBuilder mb)
{
// The way an IEntityTypeConfiguration class writes it
mb.Entity<Team>().Property(t => t.Scores);
}
var n = ctx.Teams.Count(t => t.Scores.Contains(2));
EF assembly: 10.0.12.0
RESULT: query succeeded, rows = 1
EF assembly: 11.0.0.0
RESULT: InvalidOperationException: The LINQ expression 'DbSet<Team>()
.Count(t => t.Scores
.Contains(2))' could not be translated.
Only the target framework and the EF package version differed.
- It compiles on both —
Property()is still a valid call. - By-convention discovery is unaffected. A model with no explicit configuration keeps working, so the bug is invisible in small samples and demos.
- The failure appears at query time, not at model-build or migration time.
mb.Entity<Team>().PrimitiveCollection(t => t.Scores);
EF assembly: 11.0.0.0
RESULT: query succeeded, rows = 1
This is the most actionable upgrade item in the EF Core module, so give it a concrete
instruction rather than a caution: search the codebase for .Property( calls whose
lambda returns a collection type, and change them to .PrimitiveCollection(. It is a
mechanical, reviewable change that can be done before the upgrade, on EF Core 10, because
PrimitiveCollection already exists there.
Ask the room who uses IEntityTypeConfiguration classes. In an enterprise codebase
nearly every hand will go up, and that is the population this affects. Teams relying on
conventions will not see it at all — which is worth saying, because it explains why the
issue is not more widely reported.
Lab 09 — upgrade the data model 25 min
This is Day 1's Lab 04 again, upgraded to EF Core 11 and net11.0.
cd labs/Day2/09-EfCore11-Migration/start
dotnet build
dotnet tool restore
Your tasks:
- Retarget the project to
net11.0. - Upgrade the EF Core packages and the local
dotnet-eftool. - Put an index on a complex type property.
- Use the new single-step migration creation-and-application workflow.
- Try
MinByandMaxBytranslation, then inspect the generated SQL.
The goal is not just a green build. The goal is to see the model change in a migration and the LINQ change in generated SQL.
Pair people who know the domain model with people who know deployment. EF migrations are where those two concerns meet, and the discussion is usually more valuable than the syntax.
.NET 12 outlook: signals, not features Outlook
There is no .NET 12 documentation, no preview and no released feature list today.
Previews are expected to begin in early 2027, with GA expected in November 2027 as the next LTS. This module is planning guidance based on visible direction from .NET 10 and .NET 11, not a promise of .NET 12 features.
| Release | Status today | Expected role |
|---|---|---|
| .NET 10 | GA, LTS | Adopt-now baseline, supported to 14 November 2028 |
| .NET 11 | Release Candidate 1, STS | Decision release, 24 months — also ~November 2028 |
| .NET 12 | No public previews or docs | Expected next LTS in November 2027 |
Say this slowly. The value here is trust. If someone asks for a .NET 12 feature, bring it back to signals and cadence rather than guessing.
Direction of travel from .NET 10 and 11
These are not .NET 12 features. They are the areas where the current releases clearly show unfinished or continuing work.
- Memory safety in C# is explicitly multi-release: C# 15 recognises
safe, but it has no effect on callers yet. - Unions shipped in C# 15, with parts of the original proposal still not implemented.
- Runtime-native async is default in .NET 11, and the optimisation space it opens is ongoing.
- Complex types have matured steadily through EF Core 10 and EF Core 11.
- The LTS/STS cadence remains the planning anchor.
- .NET 12 is expected to be the next LTS, not the release to plan features against today.
You can plan for cadence, support windows and migration cost. You cannot responsibly promise a .NET 12 API that has not appeared in public documentation or a preview SDK.
This is a good place to ask what decisions they have to make before early 2027. Anything after the first previews can be revisited with real SDKs.
The decision: stay on 10, take 11, or split
This is the real close of the workshop: choose the support model that matches your organisation, not the release with the longest feature list.
Most people walk into this decision believing STS buys eighteen months. Since the .NET 9 change it buys 24. Run the dates:
- .NET 10 LTS — ends 14 November 2028
- .NET 11 STS — ships November 2026, plus 24 months, ends ~November 2028
That is the same week. Taking .NET 11 costs you essentially no support runway. Whichever you pick, your next forced move is late 2028 — and in both cases the natural destination is .NET 12 LTS.
So the decision is not about support duration. Strip that out and what remains is the real question: is the upgrade work itself worth doing twice?
| Path | What it actually costs | Good fit | Watch-outs |
|---|---|---|---|
| Stay on .NET 10 LTS | One upgrade, in 2028. Supported to 14 Nov 2028 | Slow upgrade cycles, long-lived deployments, compliance or validation overhead | You skip C# 15 and runtime-async until then — and arrive at .NET 12 having never exercised unions |
| Take .NET 11 STS | Two upgrades — one now, one by late 2028. Same end date | Teams that already upgrade annually, want unions or closed hierarchies now, or need the runtime-async throughput work | The cost is the extra upgrade cycle and RC-era churn, not lost support |
| Split the estate | .NET 11 on the edge, .NET 10 in the core | New or low-risk services adopt .NET 11 while the critical estate stays put | Needs clear standards so shared libraries and tooling do not drift accidentally |
Use .NET 11 in new or low-risk services to build experience, while keeping the long-lived estate on .NET 10 LTS. That gives you evidence before the .NET 12 LTS decision — which is the decision that actually matters, because .NET 12 is where both paths converge.
This is the slide to spend time on if the schedule is tight. Attendees can read feature lists later; they need the decision model now.
The 24-month correction usually changes the conversation in the room. Teams that arrived certain they were "LTS only" discover their policy was built on support arithmetic that no longer holds — the honest reframing is that "LTS only" is now a statement about appetite for upgrade cycles, not about support runway. Let them sit with that rather than pushing a recommendation.
The .NET 12 boundary: what the release index says
A useful outlook starts by separating known facts from attractive guesses.
The local releases-index.json check found .NET 11 RC 1 as the latest .NET 11 build and found no .NET 12 channel at all. That means no .NET 12 feature list, preview SDK, API surface or support dates should be presented as fact.
- What .NET 10 and .NET 11 have already shipped or reached RC with.
- Which direction those shipped changes suggest.
- How to design an upgrade process that does not depend on a roadmap.
- Named .NET 12 APIs.
- Preview dates or GA dates.
- Performance promises, compatibility claims or support deadlines.
This slide is the safe answer to the requested “short .NET 12 outlook”. If challenged, repeat: no channel means no facts to quote. That restraint is the value.
Plan for cadence, not clairvoyance
You can build a robust upgrade plan without knowing a single future feature. Anchor on support policy, evidence gates and rehearsals.
| Planning input | Use it for | Do not use it for |
|---|---|---|
| Support policy | Budget windows, approval timing and retirement deadlines. | Predicting which API will matter next. |
| Shipped signals | Spotting areas of continuing investment: async, deployment, language safety and data access. | Promising a future roadmap item to stakeholders. |
| RC rehearsals | Finding hardware, tooling and dependency friction while rollback is cheap. | Declaring every preview-era behaviour permanent. |
| Your telemetry | Choosing which services deserve early adoption. | Generalising another team's benchmark to your estate. |
A repeatable annual rehearsal beats a speculative roadmap slide. It also makes the next LTS move smaller, because you have already found the boring infrastructure failures.
Bring the conversation back to the audience's governance process: who approves SDKs, base images, package major versions and compiler changes? Those names matter more than a future feature list.
A cadence that survives not knowing
Separate production commitment from learning. The same organisation can be conservative in production and aggressive in evidence gathering.
Keep critical services on the supported LTS baseline unless a measured business reason justifies moving sooner.
Use one or two low-risk services to test the current STS release, compiler changes and runtime diagnostics.
Track upgrade blockers as backlog items: hardware, generated clients, analyzers, package drift and deployment warnings.
If .NET 11 does not earn adoption, record why. That prevents the same debate from restarting when the next release appears.
This is a useful executive slide. It avoids making the room choose a single ideology: LTS-only production can coexist with an STS learning lane.
What to brief on Monday
Close the workshop with decisions the team can actually take back, not a speculative future-feature list.
- State the baseline. Which services stay on .NET 10 LTS, and why?
- Name the pilot. Which low-risk service is allowed to test .NET 11 RC 1?
- Define the evidence. Hardware smoke test, OpenAPI toolchain check, runtime-async artefact check, diagnostics pass and workload measurement.
- Assign owners. SDK installation, CI images, shared libraries, generated clients and observability tooling each need a named owner.
- Record the boundary. No .NET 12 feature, date or version promise appears in the plan until there is a public channel to verify.
Plan to absorb the next release train. Do not plan around imagined contents of that train.
Use this as the final hand-off. Ask for a single named pilot and a single named owner for generated clients; those two decisions usually expose the real organisational blockers.
What to do in the next 30 days
Turn the workshop into evidence while it is still fresh.
- Verify hardware baselines against .NET 11's raised minimum requirements.
- Check whether your OpenAPI client generators, gateways and contract tests accept OpenAPI 3.2.
- Audit for the C# 14
fieldbreaking change before turning on the new compiler. - If you are still on .NET 8 or .NET 9, run the .NET 10 upgrade path first.
- Pick one low-risk service as a .NET 11 RC rehearsal, and one LTS service to leave deliberately on .NET 10.
Day 1 gave you the .NET 10 LTS baseline: C# 14, libraries, ASP.NET Core and EF Core.
Day 2 showed the .NET 11 RC decision: C# 15, runtime and libraries, ASP.NET Core, EF Core, and the .NET 12 planning horizon.
Labs live under labs/; vetted sources are filled into each module by the further-reading pipeline.
Close by asking each table to name one thing they will test in their own pipeline this month. Keep it concrete: SDK install, OpenAPI import, EF migration, or language audit.