Microsoft.Extensions Breaking Changes in .NET 11: 9 Fixes

Last updated: November 11, 2026 · Tested with .NET 11.0.0 (SDK 11.0.100) and .NET 10.0.12 (SDK 10.0.401), Microsoft.Extensions.* 11.0.0 and 10.0.14, Windows 11

Short answer: the .NET 11 breaking changes list has ten Microsoft.Extensions entries. I ran nine of them on .NET 10 and .NET 11. Four can stop a build or a startup: options with an async validator throw OptionsValidationException when you read them synchronously, an [OptionsValidator] class with a handwritten Validate fails with error SYSLIB1205, scope disposal now throws AggregateException when several services fail, and references to nine Microsoft.Extensions packages give warning NU1510. The configuration changes are quieter and easier to miss.

The tenth entry, a failing BackgroundService making RunAsync throw, is covered with eight non-Extensions breaks in my .NET 11 upgrade post, so I don’t repeat it here.

SymptomCauseFix
OptionsValidationException: “validated by an asynchronous validator that cannot run during synchronous validation”Options read before async startup validation ranValidateOnStart, then await host.StartAsync() before reading
CS0535: does not implement IValidateOptions<T>.ValidateAsync validator written against a .NET 11 previewAdd the synchronous Validate method
Async validator never runs, no errorRegistered as IAsyncValidateOptions<T>Register it as IValidateOptions<T>
error SYSLIB1205 plus CS1061[OptionsValidator] with an explicit ValidateRemove the handwritten method, move the extra rule to its own validator
AggregateException from scope.Dispose()Two or more services threw while being disposedCatch AggregateException first; don’t throw from Dispose
warning NU1510 (an error with TreatWarningsAsErrors)Package is now part of the shared frameworkRemove the PackageReference
Setting is '', or binding an int throwsEmpty string in a chained configuration now winsUse null or remove the key
Async OnChange callback runs fewer times, exceptions disappearLambda binds to the new Func<Task> overloadtry/catch inside the callback
App starts with no settings when appsettings.json is lockedIO errors now reach OnLoadExceptionSet Ignore only for InvalidDataException
ArgumentException from new PhysicalFilesWatcher(...)Watcher path unrelated to the rootWatch the root, or pass an empty path

How I Tested It

One small console project per change. Each one builds twice from the same source, switched by a property in Directory.Build.props: -p:TF=net10.0 with the .NET 10 SDK and the 10.0.14 packages, and -p:TF=net11.0 with the .NET 11 SDK 11.0.100 and the 11.0.0 packages:

<ExtVersion Condition="'$(ExtVersion)' == '' And '$(TF)' == 'net10.0'">10.0.14</ExtVersion>
<ExtVersion Condition="'$(ExtVersion)' == '' And '$(TF)' == 'net11.0'">11.0.0</ExtVersion>

Some of these changes live in the NuGet packages rather than the runtime, so for DI disposal I also ran .NET 10 with the 11.0.0 package. Async options validation doesn’t exist in .NET 10, so for that one the “before” is the 11.0.0-preview.7.26381.103 package. Every output below is copied from a run. Named mutexes defaulting to user scope on Unix, which the list files under core libraries, needs Linux or macOS, and I didn’t test it.

1. Synchronous Access to Async-Validated Options Throws

Async options validation is new in .NET 11. It arrived in Preview 6, and RC1 changed the contract: IAsyncValidateOptions<T> now derives from IValidateOptions<T>. If you’re coming straight from .NET 10, this only matters once you adopt the feature. If you wrote a validator against the previews, it breaks in three ways. Here is a validator as it compiled with the Preview 7 package:

public sealed class ApiOptionsValidator : IAsyncValidateOptions<ApiOptions>
{
    public async Task<ValidateOptionsResult> ValidateAsync(string? name, ApiOptions options, CancellationToken cancellationToken = default)
    {
        await Task.Delay(10, cancellationToken); // stands in for a remote check
        return options.TimeoutSeconds > 0
            ? ValidateOptionsResult.Success
            : ValidateOptionsResult.Fail("TimeoutSeconds must be positive.");
    }
}

On .NET 11 it no longer builds:

Program.cs(29,43): error CS0535: 'ApiOptionsValidator' does not implement interface member 'IValidateOptions<ApiOptions>.Validate(string?, ApiOptions)'

That one is easy. The second is the one I didn’t expect. I added Validate and kept the Preview 7 registration:

builder.Services.AddSingleton<IAsyncValidateOptions<ApiOptions>, ApiOptionsValidator>(); // Preview 7 registration

It compiles, and with TimeoutSeconds = -5 and ValidateOnStart the host starts happily:

StartAsync: ok
IOptions.Value: TimeoutSeconds = -5

On .NET 11 the options system reads async validators from the same IValidateOptions<T> collection as the synchronous ones, so a validator registered only under the async interface is never called. No warning. Register it under the base interface:

builder.Services.AddSingleton<IValidateOptions<ApiOptions>, ApiOptionsValidator>();

Now startup rejects the bad value. The third change is the one in the title. On Preview 7, reading the options without startup validation simply returned the unvalidated value:

.NET 10: IOptions.Value: TimeoutSeconds = -5

On .NET 11, with the built-in AsyncValidateOptions<T> validator and no ValidateOnStart, every synchronous read throws, even with a valid value of 30:

.NET 11: IOptions.Value: OptionsValidationException: Options instance of type 'ApiOptions' is validated by an asynchronous validator that cannot run during synchronous validation. Synchronous creation paths cannot execute or await ValidateAsync. With the built-in options factory and accessors, use ValidateOnStart and complete startup before synchronously accessing IOptions<TOptions> or IOptionsMonitor<TOptions> so their initial values can be validated and seeded. IOptionsSnapshot<TOptions> remains synchronously validated per scope and is not populated by startup validation. IOptionsMonitor<TOptions> recreation after a change also remains synchronous; ValidateOnStart does not provide asynchronous reload validation or an asynchronous last-known-good value.

The message explains the fix, and the run confirms the last sentence. After ValidateOnStart and await host.StartAsync(), IOptions and IOptionsMonitor work, but IOptionsSnapshot in a new scope still throws:

StartAsync: ok
IOptions.Value: TimeoutSeconds = 30
IOptionsMonitor.CurrentValue: TimeoutSeconds = 30
IOptionsSnapshot.Value: OptionsValidationException: Options instance of type 'ApiOptions' is validated by an asynchronous validator that cannot run during synchronous validation. Synchronous creation paths cannot execute or await ValidateAsync. With the built-in options factory and accessors, use ValidateOnStart and complete startup before synchronously accessing IOptions<TOptions> or IOptionsMonitor<TOptions> so their initial values can be validated and seeded. IOptionsSnapshot<TOptions> remains synchronously validated per scope and is not populated by startup validation. IOptionsMonitor<TOptions> recreation after a change also remains synchronous; ValidateOnStart does not provide asynchronous reload validation or an asynchronous last-known-good value.

If anything uses IOptionsSnapshot, or options are reloaded, give your own validator a real synchronous check for the rules that don’t need I/O. In my test, SyncCheck switches between the two versions:

public ValidateOptionsResult Validate(string? name, ApiOptions options) =>
    SyncCheck ? Check(options) : ValidateOptionsResult.Fail("ApiOptions must be validated at startup.");

With the real check, a valid value reads fine from all three accessors, and an invalid one fails with your own message: OptionsValidationException: TimeoutSeconds must be positive.

One more surprise: the recommended-action sample on the Learn page for this change calls .ValidateAsync(...) on OptionsBuilder. That doesn’t compile on .NET 11.0.0:

Program.cs(9,6): error CS1061: 'OptionsBuilder<MyOptions>' does not contain a definition for 'ValidateAsync' and no accessible extension method 'ValidateAsync' accepting a first argument of type 'OptionsBuilder<MyOptions>' could be found (are you missing a using directive or an assembly reference?)

I couldn’t find that method in the 11.0.0 reference assemblies or in the 11.0.0 Hosting package. Register an AsyncValidateOptions<T> instance, or your own class, as shown above.

2. SYSLIB1205: Options Validator With an Explicit Validate

A common pattern: let the source generator handle the data annotations, then add one handwritten rule as an explicit interface method:

[OptionsValidator]
public partial class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
    // Handwritten check, added later for a rule the attributes can't express.
    ValidateOptionsResult IValidateOptions<SmtpOptions>.Validate(string? name, SmtpOptions options)
        => options.Host.EndsWith(".internal") ? ValidateOptionsResult.Fail("Host must be public.") : ValidateOptionsResult.Success;
}

On .NET 10 this builds without a warning, and it’s broken. The generator adds a second, public Validate, so the result depends on how you call it. With Port = 0:

.NET 10: via interface: Succeeded
.NET 10: via class:     Failed: Port: The field SmtpOptions.Port must be between 1 and 65535.

The options system calls through the interface, so in a real app the [Range] attribute was never checked. On .NET 11 the build stops:

Program.cs(19,1): error SYSLIB1205: Type SmtpOptionsValidator already implements the Validate method.
Program.cs(9,52): error CS1061: 'SmtpOptionsValidator' does not contain a definition for 'Validate' and no accessible extension method 'Validate' accepting a first argument of type 'SmtpOptionsValidator' could be found (are you missing a using directive or an assembly reference?)

Note that it is an error, not a warning, and the CS1061 follows because the generated method is gone. Keep the generated class empty and put the extra rule in its own validator. Both run:

[OptionsValidator]
public partial class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
}

public sealed class SmtpHostRule : IValidateOptions<SmtpOptions>
{
    public ValidateOptionsResult Validate(string? name, SmtpOptions options)
        => options.Host.EndsWith(".internal") ? ValidateOptionsResult.Fail("Host must be public.") : ValidateOptionsResult.Success;
}
services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>(); // generated: attributes
services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpHostRule>();         // handwritten: extra rule

The fix works on both versions, but the output showed a change that isn’t on the list. The generated message no longer includes the class name:

.NET 10: smtp.corp.internal: Port: The field SmtpOptions.Port must be between 1 and 65535. | Host must be public.
.NET 11: smtp.corp.internal: Port: The field Port must be between 1 and 65535. | Host must be public.

If a test asserts on the exact validation message, it will fail after the upgrade.

3. DI Scope Disposal Aggregates Exceptions

Three scoped services: one disposes cleanly, two throw. The resolve order is DbConnectionLike, FileWriter, MessageSender, and the container disposes in reverse. On .NET 10 the first exception stops everything:

.NET 10:
--- scope.Dispose()
MessageSender.Dispose called
TimeoutException: MessageSender: broker did not ack

FileWriter and DbConnectionLike were never disposed. On .NET 11 every service gets its turn, then you get one exception with both failures:

.NET 11:
--- scope.Dispose()
MessageSender.Dispose called
FileWriter.Dispose called
DbConnectionLike disposed (connection returned to pool)
AggregateException: One or more errors occurred. (MessageSender: broker did not ack) (FileWriter: disk full while flushing)
  inner TimeoutException: MessageSender: broker did not ack
  inner IOException: FileWriter: disk full while flushing

DisposeAsync() behaved the same way. With only one failing service, .NET 11 still throws the original exception, not an AggregateException:

--- scope.Dispose() with one failing service
FileWriter.Dispose called
DbConnectionLike disposed (connection returned to pool)
IOException: FileWriter: disk full while flushing

So a catch (IOException) around disposal works on .NET 11 until a second service fails, and then it doesn’t. Catch both, AggregateException first:

catch (AggregateException ex)
{
    Console.WriteLine($"AggregateException: {ex.Message}");
    foreach (var inner in ex.InnerExceptions) Console.WriteLine($"  inner {inner.GetType().Name}: {inner.Message}");
}
catch (Exception ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }

This one comes from the Microsoft.Extensions.DependencyInjection package, not the runtime. .NET 10 with the 11.0.0 package printed exactly the .NET 11 output, so you get it as soon as you update the package. The better fix is a Dispose that doesn’t throw. For more on lifetimes and scopes, see my dependency injection guide and the scoped-from-singleton error.

4. Nine Microsoft.Extensions Packages Moved Into the Shared Framework

Options, Primitives, and the Abstractions packages for DI, Configuration, Logging, Hosting, FileProviders, Diagnostics and Caching now ship with the runtime. My test project references Hosting plus two of them explicitly. On .NET 10 it builds clean. On .NET 11:

SharedFx.csproj : warning NU1510: PackageReference Microsoft.Extensions.Options will not be pruned. This package is automatically available and does not need to be referenced explicitly. Remove the PackageReference item.
SharedFx.csproj : warning NU1510: PackageReference Microsoft.Extensions.Logging.Abstractions will not be pruned. This package is automatically available and does not need to be referenced explicitly. Remove the PackageReference item.

With TreatWarningsAsErrors the build fails with error NU1510: Warning As Error. What the app loads at runtime:

.NET 10: Microsoft.Extensions.Options: 10.0.14 from app folder
.NET 10: Microsoft.Extensions.*.dll in output: 27, 1125 KB
.NET 11: Microsoft.Extensions.Options: 11.0.0 from shared framework
.NET 11: Microsoft.Extensions.*.dll in output: 19, 790 KB

The eight missing files are exactly the abstractions on the list, removed even when they only came in through Hosting. I also pinned Microsoft.Extensions.Options to 10.0.14 on .NET 11: same NU1510, and the app still loaded the 11.0.0 copy from the shared framework. The package version you reference no longer decides what runs. Removing the two references cleared the warnings with no code change. A project with <TargetFrameworks>net10.0;net11.0</TargetFrameworks> got no NU1510 at all, as documented, because the net10.0 build still needs the package.

5. Chained Configuration: Empty Strings Now Override

This affects AddConfiguration(otherConfig), which you see when shared defaults are chained into an app’s configuration. The shared part has "" values:

["Proxy:Url"] = "",          // "no proxy" in the shared defaults
["Retry:Count"] = "",
.NET 10: Proxy:Url = 'http://proxy.local:8080'
.NET 10: Feature:Beta exists = False, value = null
.NET 10: Retry.Count = 3
.NET 11: Proxy:Url = ''
.NET 11: Feature:Beta exists = True, value = ''
.NET 11: Bind Retry: InvalidOperationException: Failed to convert configuration value '' at 'Retry:Count' to type 'System.Int32'.

On .NET 10 the empty value was ignored. On .NET 11 it wins, the section exists, and binding an int throws. If you meant “not set”, use null, which is still treated as absent; the fixed version printed the .NET 10 output on both runtimes:

["Proxy:Url"] = null,        // null = absent, so the earlier value wins

6. ChangeToken.OnChange Binds Async Lambdas to a New Overload

Same source on both versions, three reloads 60 ms apart, a callback that awaits for 300 ms:

ChangeToken.OnChange(config.GetReloadToken, async () =>
{
    int id = Interlocked.Increment(ref calls);
    Log($"reload {id} start");
    await Task.Delay(300);
    if (fail) throw new InvalidOperationException($"reload {id} failed");
    Log($"reload {id} end");
});
.NET 10: callback ran 3 times for 3 reloads
.NET 11: callback ran 2 times for 3 reloads

On .NET 10 the lambda is async void and the three callbacks overlap. On .NET 11 it binds to Func<Task>, the next registration waits for the task, and the two reloads that arrived meanwhile are merged into one call. Usually that’s what you wanted. The part that surprised me is exceptions. On .NET 10 the throw crashed the process:

Unhandled exception. System.InvalidOperationException: reload 1 failed

On .NET 11 the process kept running, and the errors appeared only through TaskScheduler.UnobservedTaskException, after I forced a garbage collection:

1202 ms  UnobservedTaskException: reload 1 failed
1202 ms  UnobservedTaskException: reload 2 failed

Without that handler, a failing reload is now silent. Put a try/catch inside the callback and log. If you really need the old fire-and-forget timing, cast to Action; my cast run went back to three overlapping calls:

ChangeToken.OnChange(config.GetReloadToken, (Action)(async () =>

7. OnLoadException Now Receives IO Errors

A common handler sets Ignore = true so a bad edit to appsettings.json doesn’t take the app down. I locked the file with FileShare.None and built the configuration:

.NET 10: Build() threw IOException: The process cannot access the file '<cfg>\appsettings.json' because it is being used by another process.
.NET 11: OnLoadException: IOException: The process cannot access the file '<cfg>\appsettings.json' because it is being used by another process.
.NET 11: Build() returned, Mode = null

On .NET 10 the IO error skipped the handler and the app failed fast. On .NET 11 it reaches the handler, the handler ignores it, and the app starts with no settings at all. Check the type:

ctx.Ignore = ctx.Exception is InvalidDataException;

With that line the locked file throws again on .NET 11, and a broken JSON edit is still ignored. Don’t cast ctx.Exception to InvalidDataException either; it can now be any type.

8. No Reload Token After an Ignored Load Failure

Same handler, a file that becomes invalid JSON, then provider.Load():

.NET 10: OnLoadException: InvalidDataException: Failed to load configuration from file '<cfg>\appsettings.json'.
.NET 10: provider reload token fired
.NET 11: OnLoadException: InvalidDataException: Failed to load configuration from file '<cfg>\appsettings.json'.
.NET 11: after failed load, Mode = v1

On .NET 11 the provider’s reload token doesn’t fire, because no new data was loaded. The token fired again on both versions after a good load. If you ran code from the token after a failure, call it from OnLoadException instead.

9. PhysicalFilesWatcher Validates Its Paths

On .NET 10 every constructor call I tried succeeded. On .NET 11 two of them throw at construction:

watcher on an unrelated folder: ArgumentException: The FileSystemWatcher path '<app>\watch-other\' must be empty, equal to, an ancestor of, or a descendant of the root '<app>\watch-root\'. (Parameter 'fileSystemWatcher')
null root: ArgumentNullException: Value cannot be null. (Parameter 'root')

A relative root, a watcher on the root itself, and a root that doesn’t exist yet all worked on both. Most apps never build this class directly; it matters if you wrap PhysicalFileProvider or write your own file watcher.

Checklist

  • Search for IAsyncValidateOptions: add Validate, and register as IValidateOptions<T>.
  • Use ValidateOnStart and finish StartAsync before reading async-validated options.
  • Give async validators a real synchronous check if you use IOptionsSnapshot or reloads.
  • Remove handwritten Validate methods from [OptionsValidator] classes.
  • Don’t assert on generated validation messages; the class name is gone.
  • Catch AggregateException and the single exception around scope disposal.
  • Remove PackageReference entries that cause NU1510.
  • Replace "" with null in chained configuration when you mean “not set”.
  • Add try/catch to async ChangeToken.OnChange callbacks.
  • Set OnLoadException‘s Ignore only for InvalidDataException.
  • Expect the DI disposal change as soon as you update the package, even on .NET 10.

Related: upgrading to .NET 11: 9 breaking changes · dependency injection in C# · cannot consume scoped service from singleton · Microsoft’s .NET 11 breaking changes list · all ASP.NET Core tutorials

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *