What's New in .NET — Day 2

.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.

Status

.NET 11 and C# 15 are at Release Candidate 1. General availability is expected in November 2026.

Support

.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.

Today's question

Which changes create enough value, or enough risk, to justify an annual upgrade cadence in your environment?

RC means close, not frozen

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.

Measured on RC 1: it is opt-in, not default

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:

Default net11.0
state machines: 4
Deep3 has AsyncStateMachineAttribute: True
With runtime-async=on
state machines: 0
Deep3 has AsyncStateMachineAttribute: False
The stack-trace demo does not work — don't try it

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.

So why should architects care?

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.

Migration risk: old and virtual hardware

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.

Where to check
  • Production VM images and base container hosts
  • Self-hosted CI agents and disaster-recovery runners
  • Edge, kiosk, factory-floor and long-lived appliance hardware
How to keep the risk small
  • Run a tiny net11.0 smoke 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.

AreaWhat changedHow to teach it
Vector constructionCreateGeometricSequenceUseful for generated numeric sequences without scalar setup loops
Vector rearrangementZip, Unzip, Concat familyShow as data-shaping operations, not hand-written intrinsics
ArmSVE2 supportRelevant for Arm64 server and cloud capacity planning
x64AVX-VNNI-512Relevant where numeric or ML-style kernels dominate
Keep this evidence-led

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.

Command-line quality of life
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.

Build and test surface
  • Solution filters are supported through dotnet sln.
  • NativeAOT CLI and the MSBuild server are on by default.
  • dotnet test has 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.

Default net11.0
runtime : .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
With runtime async
runtime : .NET 11.0.0-rc.1.26425.128
result  : 1
state machines: 0
Deep3 has AsyncStateMachineAttribute: False
Measured on RC 1: the names go away

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;
When to use it

Auditing your own packages, plug-ins or shared libraries after retargeting to net11.0.

What it proves

Whether the deployed assembly still carries the classic compiler state-machine representation.

What it does not prove

It is not a throughput benchmark and it does not tell you whether the code path is hot.

Make the audit part of upgrade evidence

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.

ConcernGuidanceWhy it matters
Public signaturesKeep returning Task, Task<T> or ValueTask<T> as today.Consumers should not need source changes to consume a runtime-async build.
Binary compatibilityDo 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-targetingBe explicit about which target and build settings produced the package you ship.net11.0 alone did not enable the measured RC 1 behaviour.
TestsAssert behaviour and cancellation semantics, not internal compiler artefacts.Representation-sensitive tests become upgrade blockers for the wrong reason.
RC-era caution

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.

Tools to review
  • Profilers that group costs by generated MoveNext methods
  • Analysers that find async code by IAsyncStateMachine
  • Telemetry enrichment that demangles <Method>d__N names
  • Coverage or mocking tools that special-case compiler-generated async types
Evidence to capture
  • 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
The absence is the signal

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.

  1. Pick one async-heavy path. HTTP fan-out, database access, queue processing and SDK clients are better candidates than CPU-only loops.
  2. Build two artefacts from the same commit. Default net11.0 and the same project with the runtime-async feature switch.
  3. Measure the same load. Capture throughput, latency percentiles, allocations and GC activity. Do not paste invented numbers into the deck.
  4. 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>
The decision threshold

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.

Reflection

Serialisers, plug-in discovery and convention-heavy frameworks need explicit tests when trimming enters the build.

Startup

Measure cold start separately from steady-state throughput. They answer different business questions.

Containers

Validate the published image, not just dotnet run. Deployment shape is part of runtime behaviour.

A runtime upgrade is a packaging rehearsal

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.

ChooseWhen the evidence saysRuntime checks before you commit
Stay on .NET 10Your 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 11A 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 selectivelyNew 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.
RC 1 is go-live, not a blank cheque

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);
Measured: same source, both SDKs, Release build
Expression.NET 10.0.12.NET 11 RC 1
(byte)300.044255
(byte)-1.02550
(short)70000.0446432767
(int)1e20f21474836472147483647
(uint)-5.000

.NET 10 converted to a wide integer and kept the low bits. .NET 11 clamps to the destination range.

What actually changed
  • Only the narrowing cases: byte, sbyte, short, ushort.
  • int and uint already saturated in .NET 10 — that dates from .NET Core 3.0 and is unchanged.
Why (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.
Where to look in your own code

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:

  1. Run it on default net11.0 and count the types in your own assembly that implement IAsyncStateMachine.
  2. Add <Features>$(Features);runtime-async=on</Features> and count again.
  3. Capture the stack trace in both configurations and on net10.0, and compare them honestly — you should find no difference at all.
  4. Explain why none of the application async/await source code changed, and where the cost saving actually comes from.
The point of the lab

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.

FeatureWhat it replacesStatus in RC 1
Union typesNullable bags, exception-as-flow, ad-hoc result hierarchiesDefault for net11.0
closed hierarchiesOpen inheritance with defensive defaultsDefault for net11.0
Collection expression argumentsManual constructors before collection expressionsDefault for net11.0
Extension indexersHelper methods such as At(index)Default for net11.0
Labeled break/continueBoolean flags and small goto workaroundsDefault for net11.0
Memory-safety workOver-broad unsafe regionsPreview opt-in still required
Important default

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.

RC 1 reality check

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 transitive

An 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 forWhenExample
unionThe case types are unrelated, or you do not own all of themAPI result can be success, validation failure or not found
closedYou own the hierarchy and want common members, constructors or behaviourDomain event family, discount rule family, workflow step family
Neither yetThe set is not actually closed, or plugin authors must add casesExtensibility points, external provider models
The safety net is off by default — turn it on

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>
Then the compile error is the feature

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
Measured on RC 1 — the capacity is exact

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 arguments3

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
Do not hide expensive access

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
Read that output again — a3 never ran

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.

The analyser nudge

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.

Measured: this is not a C# 15 feature

Same project, only LangVersion changed. Pointer declaration, address-of and sizeof outside an unsafe context:

LangVersionResult
15.0error CS0214 × 3 — "Pointers and fixed size buffers may only be used in an unsafe context"
previewbuilds 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.

Relaxed outside unsafe context
  • Declaring pointer types
  • Taking an address with &
  • fixed
  • stackalloc to a pointer
  • sizeof
Still constrained
  • Dereferencing still requires unsafe.
  • unsafe(expr) can be used in field initializers, constructor initializers and catch filters.
  • safe is recognised, but has no effect on callers yet.
<PropertyGroup>
  <TargetFramework>net11.0</TargetFramework>
  <LangVersion>preview</LangVersion>
  <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
This is the exception to the default-language rule

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.

ConstructAbsent stateHow it reaches youWhat happens
uniondefault(Pet)default(T), arrays, unassigned fields, zeroed storageNo arm matches
closed classnullNullable flows, deserialisation, APIs returning nothingNo 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"
};
Measured on .NET 11 RC 1: both holes compile cleanly

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.

Reflection view
  • IsValueType is True
  • Base type is ValueType
  • Implements IUnion
  • Has UnionAttribute
  • Exposes object Value
Design consequence

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)
This is the reason for the default hole

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.

OperationRC 1 resultTrap
a == bDoes not compileRecord cases do not lift their operator to the union
a.Equals(b)True for equal record payloadsEquality exists, just not where many developers look first
pet.GetType()PetThe wrapper type is visible
pet.ToString()PetLogging does not show the record payload
pet is DogTruePattern matching sees through the wrapper
Pet a = new Cat("Tom");
Pet b = new Cat("Tom");

// a == b    // CS0019
bool same = a.Equals(b);
Measured: equality and display are not symmetric

== 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.

Does not work
Pet pet = new Dog("Rex");

var dog = (Dog)pet; // CS0030

A cast from the wrapper to a case type is not defined.

Use a match
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.

Measured: casts fail, Value is only object

The 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 caseBytes per callReading
Raw int0.00No allocation baseline
union over int24.00The value-type case boxes
Raw record24.00The record allocation you already had
union over record24.00No extra wrapper allocation observed
Measured with 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.

Adoption rule

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
Generic

Result<T> lets you carry the successful value type through the model instead of falling back to object.

Value cases

Num accepted both integer and double cases, including implicit construction from literals.

Nested

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
Measured: all three compiled and ran

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.

QuestionPrefer unionPrefer closed
Do the cases share behaviour?No; matching is the shared operationYes; common members, validation or helpers belong on the base
Do cases already exist?Yes; you can wrap types you do not want to re-parentNo; you own the hierarchy and can design it together
Will external plugins add cases?Neither is a good fit for the open boundaryNeither is a good fit for the open boundary
Are primitive cases involved?Possible, but measure allocationsUsually model as named derived types instead
Do you need inheritance polymorphism?NoYes
The boundary matters more than the syntax

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.

Before
public abstract class Discount
{
    public required string Code { get; init; }
}

public sealed class Percentage : Discount;
public sealed class FixedAmount : Discount;
After
public closed class Discount
{
    public required string Code { get; init; }
}

public sealed class Percentage : Discount;
public sealed class FixedAmount : Discount;
CheckWhy it matters
External derivationsA hierarchy used as an extensibility point should not become closed without a replacement extension model.
Switch defaultsDefault arms may hide places that should now make an explicit decision for each derived type.
Null boundariesclosed improves case coverage, not null-state safety.
Warning policyPromote CS8509 if you want stale switches to block the build.
Measured shape after the keyword

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.

Null-state analysis asks

Can this expression be null according to the annotations and flow the compiler can see?

Exhaustiveness asks

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"
};
Measured: a non-nullable-looking switch can still throw

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.

Boundary rule

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.

StageAdoptGuardrail
1closed for internal hierarchies you already ownCheck there are no supported external derived types
2Reference-type unions for result models and workflow outcomesAdd absent-state arms and promote CS8509
3Generic unions where they remove duplicate result wrappersKeep extraction through pattern matching
4Value-type unions on measured paths onlyBudget for boxing or avoid in hot loops
5Extension indexers and labelled loops selectivelyUse only where the member shape makes cost and control flow clearer
RC 1 adoption stance

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.

The first production pattern

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
Measured on RC 1
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.

What this rules out
  • Select(x => (Pet)x) and any EF Core projection into a union.
  • Union-returning selectors in any IQueryable provider.
  • Moq Setup and other expression-tree APIs.
  • Anything built on Expression<Func<...>>.
The shape that works
  • 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();
Put the two halves of Day 2 together

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
Measured on .NET 11 RC 1, Release build
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.

Scope this honestly — it depends on your .NET 10 SDK

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.

How narrow is the trigger?
  • A method named exactly with…
  • …called as the first element of a collection expression.
  • Rare — but fluent builder helpers do get named with.
What to do about it
  • Search for with( as a method declaration, not a record expression.
  • 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:

  1. Model the starter domain result as a union.
  2. Model the same choices as a closed hierarchy.
  3. Use exhaustive switch expressions with no default arm.
  4. Add a new case and watch every incomplete switch fail to compile.
  5. Decide which model better fits the domain and write down why.
That red build is intentional

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.

Application code

System.Text.Json, LINQ joins, async validation and HTTP body compression are the most likely to show up in everyday services.

Platform code

Zstandard, decimal floating point, generic complex numbers and zero-copy streams matter where libraries sit close to data movement.

Security and networking

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.PascalCase for 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.
Unions serialize by default. They do not deserialize.

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.

It refuses to guess — and it tells you early

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
Unmatched value types surface as 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.

Good fit

Reconciling two sequences where missing-on-left and missing-on-right are both business outcomes: imports, account lists, inventory snapshots.

Careful with

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.

Compression
  • Zstandard support in System.IO.Compression.
  • CRC32 validation on ZIP reads.
Numerics
  • IEEE 754 decimal floating point: Decimal32, Decimal64, Decimal128.
  • Generic Complex<T>.
  • TryParsePartial for parser-style flows.
Adoption is workload-specific

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.

Async validation
  • AsyncValidationAttribute
  • IAsyncValidatableObject
  • Validator.ValidateObjectAsync
  • IAsyncStartupValidator

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.

Crypto and networking
  • X25519 Diffie-Hellman.
  • Unpadded AES Key Wrap.
  • TLS handshake hardening.
  • Channel-binding validation on Unix.
  • DnsResolver typed 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 teachTeach thisWhy it matters
System.Decimal128System.Numerics.Decimal128A lab with only using System; will not compile.
X25519X25519DiffieHellmanThe API is a Diffie-Hellman algorithm, with provider-specific variants.
DataAnnotations startup validationMicrosoft.Extensions.Options.IAsyncStartupValidatorThis is the options validation pipeline gaining an async path.
Verified against .NET 11 RC 1 reference assemblies

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.

Stream path

ZstandardStream is the familiar replacement point when code already wraps a stream with Gzip, Deflate or Brotli.

Advanced path

ZstandardEncoder and ZstandardDecoder are the lower-level shape for libraries that manage buffers directly.

The differentiator

ZstandardDictionary lets many small similar payloads share learned structure rather than paying the same discovery cost each time.

Measured RC 1 surface

The present family includes ZstandardStream, ZstandardEncoder, ZstandardDecoder, ZstandardDictionary, ZstandardCompressionOptions and ZstandardDecompressionOptions.

Decision rule

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.

Use the new types when
  • 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 decimal habits.
Keep using existing choices when
  • decimal already models money correctly in your domain.
  • double is acceptable and binary floating-point compatibility is expected.
  • You cannot prove the external decimal floating-point requirement.
using System.Numerics;

Decimal128 amount = default;
Namespace correction

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 scopeThrowing-style nameTry-style name
ObjectValidateObjectAsyncTryValidateObjectAsync
PropertyValidatePropertyAsyncTryValidatePropertyAsync
ValueValidateValueAsyncTryValidateValueAsync
Attribute model

AsyncValidationAttribute is the attribute extension point for validations that cannot be answered synchronously.

Object model

IAsyncValidatableObject is the object-level counterpart for checks that involve more than one member.

Six measured method names

The async Validator surface measured in RC 1 is ValidateObjectAsync, ValidatePropertyAsync, ValidateValueAsync, TryValidateObjectAsync, TryValidatePropertyAsync and TryValidateValueAsync.

Do not over-promise integration points yet

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.

Before

Startup validation was naturally synchronous. Anything involving I/O tended to move into hosted services, custom bootstrapping code or blocking calls.

With .NET 11

IAsyncStartupValidator sits beside IStartupValidator in Microsoft.Extensions.Options, so options validation can have an async path.

Measured placement

Microsoft.Extensions.Options.IStartupValidator and Microsoft.Extensions.Options.IAsyncStartupValidator are side by side. IAsyncStartupValidator is not a System.ComponentModel.DataAnnotations type.

Good startup checks

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.

1. Choose the contract

Structural classification is attractive when each case already has a distinct JSON shape and you do not want to add a discriminator.

2. Register both sides

Add the classifier to every producer and consumer option set. A queue or cache boundary makes one-sided registration fail late.

3. Test ambiguity

Two cases with the same required shape are a contract bug. Write a test for the ambiguous case before you ship the wire format.

Measured extension point

System.Text.Json.Serialization.JsonUnionTypeStructuralClassifier exists in System.Text.Json in the .NET 11 RC 1 reference assemblies.

When structural classification is the wrong tool

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.

Contract naming

JsonNamingPolicy.PascalCase gives a built-in policy for contracts that intentionally use .NET-style member names.

Streaming output

JsonSerializer.SerializeAsyncEnumerable makes top-level async sequences a first-class shape, useful for APIs and files that stream items instead of buffering a list.

Writer lifecycle

Utf8JsonWriter.Reset helps high-throughput code reuse writer instances after the destination changes.

Pipe-friendly targets

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.
Measured member names

JsonNamingPolicy.PascalCase, JsonSerializer.SerializeAsyncEnumerable and Utf8JsonWriter.Reset are present in the .NET 11 RC 1 reference assemblies.

No invented output

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.

What X25519 is

X25519 is an elliptic-curve Diffie-Hellman key agreement algorithm. It belongs in the “derive a shared secret” family, not the signature family.

How it fits the story

It complements, rather than replaces, the PQC migration discussion. Hybrid and transition designs need clear classical and post-quantum roles.

Measured names, not shorthand

The real .NET 11 names are System.Security.Cryptography.X25519DiffieHellman, X25519DiffieHellmanCng and X25519DiffieHellmanOpenSsl. There is no bare X25519 type in the measured reference assemblies.

Teaching contrast

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.

Good fit
  • Service discovery that needs typed DNS records.
  • Diagnostics that should show resolver behaviour without shelling out.
  • Libraries that need consistent DNS policy across platforms.
Still be careful
  • 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.
Measured names

System.Net.DnsResolver and DnsResolverOptions are present in System.Net.NameResolution in the .NET 11 RC 1 reference assemblies.

RC 1 caution

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:

  1. Round-trip a C# 15 union through System.Text.Json.
  2. Use PascalCase naming and verify the JSON contract.
  3. Use FullJoin to reconcile two sequences.
  4. Deliberately hit the default(T) ambiguity with integers.
  5. Fix the projection so missing values are unambiguous.
What success looks like

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.

Contracts

OpenAPI 3.2 by default, C# union result shapes, SSE described in the document, binary file response descriptions and obsolete API reflection.

Endpoints

HTTP QUERY, async Minimal API validation, localization, non-experimental validation attributes, and attribute-based short-circuiting.

Operations

Native OpenTelemetry tracing, Kestrel TLS handshake observability, Zstandard compression, and more accurate rate-limiting headers.

RC 1 means verify, then decide

.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.

This is a real upgrade break

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.

Measured on this machine, not quoted

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:

RegistrationEmitted openapi
AddOpenApi("v1") — default3.2.0
o.OpenApiVersion = OpenApiSpecVersion.OpenApi3_13.1.2
o.OpenApiVersion = OpenApiSpecVersion.OpenApi3_03.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.

What changes first
  • The openapi value 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.
How to handle it

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.

SSE catches up with the contract

.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.

Validation
  • 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 surface
  • HTTP QUERY method support.
  • Accurate Retry-After from rate limiting.
  • HTTP/3 starts request processing earlier.
  • dotnet user-jwts works for file-based apps.
Why async validation matters

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.

First-party MCP starting point
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.

Production plumbing
  • 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.
Template names are worth checking on the installed SDK

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.

Document size

115 lines on both runtimes.

Changed lines

2 lines: the OpenAPI version and the server URL formatting.

Contract body

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
Measured on the presenter machine

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.

Measured before and after
{
  "net10": { "servers": [{ "url": "http://localhost:5285/" }] },
  "net11": { "servers": [{ "url": "http://localhost:5035" }] }
}
Why it can break
  • 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 ports are noise; the slash is the signal

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.

What the shape means

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.

What not to say

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.

Verified negative

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.

Toolchain gates
  1. Generate the document in CI.
  2. Run the exact client generators you ship from.
  3. Import into your API gateway or developer portal.
  4. Run contract diff and governance rules against the generated file.
Assertions to harden
  • Do not exact-match patch digits in the openapi field.
  • Normalise server URLs before comparing snapshots.
  • Fail on semantic contract drift, not harmless document ordering.
  • Track any 3.1 pin as temporary technical debt.
Documented plus measured, but your tools decide

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");
Keep

A pinned document for tooling that only understands the older family.

Add

A 3.2 document for toolchain pilots, gateway trials and new consumers.

Remove

The pin once every required consumer accepts the 3.2 document.

Measured side by side

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));
What improves

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.

What still needs testing

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.

Documented, not yet measured in this workshop

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);
    }
}
Return the SSE result, not the enumerable

Microsoft's ASP.NET Core 11 notes state that returning IAsyncEnumerable<SseItem<T>> directly is serialized as JSON. Use TypedResults.ServerSentEvents.

Documented, not yet measured in this workshop

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;
    }
}
Use it for

Uniqueness checks, policy services, reference-data lookups and other validation rules that already need asynchronous work.

Do not hide

If a validator is async-only, throw from the synchronous path so older validation APIs cannot silently skip the real rule.

Documented, not yet measured in this workshop

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.

Identity time

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>();
TLS binding

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.
}
RC 1: validate with your hosting mode

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.

Rate limiting

FixedWindowRateLimiter reports a more accurate RetryAfter metadata value for the next window boundary.

Compression

ASP.NET Core supports Zstandard for response compression and request decompression, and enables zstd support by default in the middleware.

Shared caches

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
    };
});
Documented, not yet measured in this workshop

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.

Before retargeting
  • 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.
After retargeting
  • 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.
Keep preview risk visible

.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:

  1. Retarget the API project to net11.0 and build it with the RC 1 SDK.
  2. Generate the OpenAPI document before and after the retarget.
  3. Diff the two documents and discover the 3.1 to 3.2 change yourself.
  4. Convert one endpoint to return a C# 15 union of result shapes.
  5. Add an async validator and watch how the endpoint behaviour changes.
Hitting the break by hand is the lab

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.

Model

Complex types now work with more inheritance and provider scenarios, and can participate in keys and indexes.

Queries

Better SQL for joins, FullJoin, GroupBy work, no-op cast stripping and MaxBy/MinBy.

Workflow

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.

New reach
  • TPT and TPC inheritance support.
  • Complex types on Cosmos.
  • Lambda chaining configuration.
The practical unlock
  • 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);
Why this changes modelling advice

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.

AreaWhat changesWhy you care
JoinsBetter SQL for to-one joins; FullJoinCleaner plans and parity with LINQ's new full outer join shape
GroupingGroupBy enhancementsMore server-side work, fewer client-side surprises
Clean-upNo-op CAST strippingLess noisy SQL, fewer avoidable plan differences
ExtremaMaxBy and MinBy translationUse the LINQ you meant, not an awkward order-and-first rewrite
JSONEF.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.

Walkthrough, not the runnable lab

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
  • 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.

JSON, text and time
  • Full-text search improvements.
  • JSON_CONTAINS and 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.

Command flow
  • Create and apply a migration in a single step.
  • Exclude foreign-key constraints when generating migrations.
  • Use -NoBuild from Package Manager Console.
Project shape
  • The latest migration ID is recorded in the snapshot.
  • A dotnet ef configuration 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
The workshop uses local tools

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.

Look here
  • System.Linq.Queryable.FullJoin
  • System.Linq.Queryable.LeftJoin
  • System.Linq.Queryable.RightJoin
  • System.Linq.Queryable.MaxBy / MinBy
Not here
  • EntityFrameworkQueryableExtensions
  • RelationalQueryableExtensions
  • An EF-specific namespace import
  • A new provider API you call directly
Measured correction

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
FullJoin0202
LeftJoin2323
RightJoin2323
MaxBy2233
MinBy2233

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.

Why this matters in the room

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
    });
Captured against SQLite, and it executed
SQL> SELECT "c"."Name", "o"."Total"
     FROM "Customers" AS "c"
     FULL JOIN "Orders" AS "o" ON "c"."Id" = "o"."CustomerId"
executed OK, 2 rows
Provider support is still the contract

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.

Readable LINQ
var newest = db.Orders
    .Where(o => o.CustomerId == 1)
    .MaxBy(o => o.CreatedAt);
Equivalent shape
var newest = db.Orders
    .Where(o => o.CustomerId == 1)
    .OrderByDescending(o => o.CreatedAt)
    .FirstOrDefault();
Captured SQL shows server-side execution
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.

Deferred query
IQueryable<Order> query = db.Orders
    .Where(o => o.CustomerId == 1);

Console.WriteLine(query.ToQueryString());

Good for a query you still hold as IQueryable<T>.

Immediate operator
optionsBuilder.LogTo(
    Console.WriteLine,
    [RelationalEventId.CommandExecuted]);

Use this when the LINQ call returns an entity, scalar or aggregate immediately.

Why the demo uses logging

The measured MaxBy finding used LogTo with RelationalEventId.CommandExecuted, because MaxBy returns the row, not an IQueryable<T> you can inspect with ToQueryString().

Turn this into an upgrade habit

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.

Refused

A non-translatable predicate in the database part of the query throws instead of silently filtering the table in memory.

Allowed

Top-level projection can still run client code after the database returns the selected values.

Explicit

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();
CI guardrail that actually catches regressions

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 needPreferReason
Single value object, queried by individual membersComplex type mapped to columnsMembers can participate in normal relational predicates and indexes.
Document-shaped value, usually loaded as a wholeComplex type mapped to JSONKeeps ownership in one row, but provider JSON query support matters.
Collection of values in a relational rowComplex collection mapped to JSONGood for contained lists, not for independent lifecycle or joins.
Collection needing joins, FKs or separate permissionsOwned collection or real entityA 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);
});
Documented, not yet measured in this deck

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.

Good JSON fit
  • Contained value objects that move with the owner.
  • Occasional predicates over well-known paths.
  • Document fields that do not need foreign keys.
Poor JSON fit
  • 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\")"));
Documented, not yet measured here

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?

Bundle

Ship a single executable migration runner for controlled deployment environments.

dotnet ef migrations bundle `
  --configuration Release `
  --self-contained false `
  --output .\artifacts\migrate.exe
Idempotent SQL

Give DBAs and release gates an artefact they can inspect before applying.

dotnet ef migrations script `
  --idempotent `
  --no-build `
  --output .\artifacts\migrate.sql
Direct update

Best for local development and throwaway environments, rarely the right production control point.

dotnet ef database update `
  --no-build
--no-build is a pipeline sharpener

Build 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.

  1. Upgrade EF packages and the local dotnet-ef tool together. Keep runtime and design-time versions aligned.
  2. Regenerate a migration and read it. Complex-member indexes, provider defaults and snapshot changes show up there first.
  3. Capture SQL for hot queries. Focus on joins, extrema, grouping, JSON predicates and provider-specific functions.
  4. Run the same tests on the real provider. SQLite is an excellent lab provider, not proof that SQL Server or PostgreSQL SQL is identical.
  5. Retest client-visible ordering and paging. Query improvements can still change plans, null handling or result order when previous code relied on accidental behaviour.
RC 1 posture

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.

LeverUse whenEF 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.
ProjectionYou need a DTO, not the whole entity graph.Still beats loading columns or navigations you do not use.
Compiled queriesThe same query shape runs very often with different parameters.Still useful for stable hot paths; not a substitute for good SQL.
BatchingYou save multiple changes and the provider can batch commands.Still provider-tuned; measure rather than copying one batch size everywhere.
SQL inspectionA 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());
Do not benchmark the upgrade in isolation

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 areaDemo postureRisk to call out
FullJoinMeasured on SQLite RC 1 setup.Requires an engine/provider that accepts FULL JOIN; older SQLite engines do not.
MaxBy / MinByMeasured on SQLite with server-side ordering and limit.Immediate operators need logging to prove translation.
SQL Server vectorsWalkthrough unless SQL Server 2025 or Azure SQL is available.Vector indexes and search functions are provider features, not general EF promises.
JSON predicates and indexesRun on the audience's own provider before teaching as reusable.Syntax, index support and path semantics vary sharply.
Temporal period mappingSQL Server-specific discussion.Do not imply SQLite or PostgreSQL have the same temporal model through EF.
The safe demo rule

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));
Measured: identical source, two paired projects, SQLite
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.

Why nothing warns you
  • 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.
The fix, verified in the same harness
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:

  1. Retarget the project to net11.0.
  2. Upgrade the EF Core packages and the local dotnet-ef tool.
  3. Put an index on a complex type property.
  4. Use the new single-step migration creation-and-application workflow.
  5. Try MinBy and MaxBy translation, then inspect the generated SQL.
The test is the 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.

Treat any .NET 12 feature list as speculation

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.

ReleaseStatus todayExpected role
.NET 10GA, LTSAdopt-now baseline, supported to 14 November 2028
.NET 11Release Candidate 1, STSDecision release, 24 months — also ~November 2028
.NET 12No public previews or docsExpected 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.

Language and runtime
  • 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.
Data and cadence
  • 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.
Do not build a roadmap on rumours

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.

Check your assumption before you decide

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?

PathWhat it actually costsGood fitWatch-outs
Stay on .NET 10 LTSOne upgrade, in 2028. Supported to 14 Nov 2028Slow upgrade cycles, long-lived deployments, compliance or validation overheadYou skip C# 15 and runtime-async until then — and arrive at .NET 12 having never exercised unions
Take .NET 11 STSTwo upgrades — one now, one by late 2028. Same end dateTeams that already upgrade annually, want unions or closed hierarchies now, or need the runtime-async throughput workThe 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 coreNew or low-risk services adopt .NET 11 while the critical estate stays putNeeds clear standards so shared libraries and tooling do not drift accidentally
The middle path is often the right path

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.

Checked on this machine: no .NET 12 channel

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.

Safe to say
  • 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.
Not safe to say
  • 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 inputUse it forDo not use it for
Support policyBudget windows, approval timing and retirement deadlines.Predicting which API will matter next.
Shipped signalsSpotting areas of continuing investment: async, deployment, language safety and data access.Promising a future roadmap item to stakeholders.
RC rehearsalsFinding hardware, tooling and dependency friction while rollback is cheap.Declaring every preview-era behaviour permanent.
Your telemetryChoosing which services deserve early adoption.Generalising another team's benchmark to your estate.
The deliverable is an operating rhythm

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.

LTS lane

Keep critical services on the supported LTS baseline unless a measured business reason justifies moving sooner.

STS lane

Use one or two low-risk services to test the current STS release, compiler changes and runtime diagnostics.

Evidence lane

Track upgrade blockers as backlog items: hardware, generated clients, analyzers, package drift and deployment warnings.

Make “not yet” an explicit decision

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.

  1. State the baseline. Which services stay on .NET 10 LTS, and why?
  2. Name the pilot. Which low-risk service is allowed to test .NET 11 RC 1?
  3. Define the evidence. Hardware smoke test, OpenAPI toolchain check, runtime-async artefact check, diagnostics pass and workload measurement.
  4. Assign owners. SDK installation, CI images, shared libraries, generated clients and observability tooling each need a named owner.
  5. Record the boundary. No .NET 12 feature, date or version promise appears in the plan until there is a public channel to verify.
The safest outlook is operational

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.

  1. Verify hardware baselines against .NET 11's raised minimum requirements.
  2. Check whether your OpenAPI client generators, gateways and contract tests accept OpenAPI 3.2.
  3. Audit for the C# 14 field breaking change before turning on the new compiler.
  4. If you are still on .NET 8 or .NET 9, run the .NET 10 upgrade path first.
  5. Pick one low-risk service as a .NET 11 RC rehearsal, and one LTS service to leave deliberately on .NET 10.
Two-day recap

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.