Skip to content

[Breaking change]: Synchronous access of async-validated options now throws instead of skipping validation #55794

Closed

Description

@ViveliDuCh

Description

Asynchronous options validation is a new .NET 11 feature (introduced in Preview 6 by dotnet/runtime#128788). Before it shipped in a stable release, .NET 11 RC 1 reworks its contract: Microsoft.Extensions.Options now unifies the synchronous and asynchronous options-validation contracts. IAsyncValidateOptions<TOptions> now derives from IValidateOptions<TOptions>, so asynchronous validators participate in the same validator collection as synchronous ones. As a result, when an options type is validated by an asynchronous-only validator and is accessed through a synchronous creation path, options creation now fails fast with an OptionsValidationException instead of silently returning an unvalidated instance.

This is a prerelease-to-prerelease change that affects code written against the .NET 11 Preview 6/7 async-validation API; it does not affect any stable (RTM) release. Introduced by dotnet/runtime#131197; approved API proposal dotnet/runtime#130719 (originally approved as dotnet/runtime#128100). Related follow-up proposal (not part of this change): dotnet/runtime#131906.

Version

.NET 11 RC 1

Previous behavior

The behavior below is the .NET 11 Preview 6/7 behavior of the then-new async options-validation API, so this migration targets preview adopters of that feature—not consumers of any stable release.

  • IAsyncValidateOptions<TOptions> was an independent, contravariant (in TOptions) interface, separate from IValidateOptions<TOptions>. Async validators ran only on the asynchronous startup-validation path.
  • On synchronous creation paths—IOptions<TOptions>.Value, IOptionsMonitor<TOptions>.CurrentValue / .Get(name), IOptionsSnapshot<TOptions>.Value, and IOptionsFactory<TOptions>.Create(name)—async validators were not executed. The options instance was returned unvalidated (validation was silently skipped).
  • A type could implement IAsyncValidateOptions<TOptions> by providing only ValidateAsync.
services.AddOptions<MyOptions>()
    .Configure(o => o.Value = -1)
    .ValidateAsync(o => Task.FromResult(o.Value > 0), "Value must be positive.");

// Synchronous access: The async validator was skipped, so an invalid instance
// was returned silently with no exception.
MyOptions value = provider.GetRequiredService<IOptions<MyOptions>>().Value; // Value == -1

New behavior

  • IAsyncValidateOptions<TOptions> now derives from IValidateOptions<TOptions>, and the in contravariance is removed.
  • On a synchronous creation path, the inherited Validate returns a failed ValidateOptionsResult, so OptionsFactory<TOptions> throws OptionsValidationException. The message directs you to run ValidateOnStart and complete startup before synchronously accessing the options.
  • The built-in AsyncValidateOptions<TOptions, ...> types now implement the synchronous Validate method (fail-fast). Custom types that implement IAsyncValidateOptions<TOptions> directly must now also implement Validate (inherited from IValidateOptions<TOptions>).
services.AddOptions<MyOptions>()
    .Configure(o => o.Value = -1)
    .ValidateAsync(o => Task.FromResult(o.Value > 0), "Value must be positive.");

// Synchronous access now throws instead of returning an unvalidated instance.
MyOptions value = provider.GetRequiredService<IOptions<MyOptions>>().Value;
// Throws OptionsValidationException:
// "Options instance of type 'MyOptions' is validated by an asynchronous validator that
//  cannot run during synchronous validation. ... use ValidateOnStart and complete startup
//  before synchronously accessing IOptions<TOptions> or IOptionsMonitor<TOptions> ..."

Recommended pattern:

services.AddOptions<MyOptions>()
    .Configure(o => o.Value = 42)
    .ValidateAsync(o => Task.FromResult(o.Value > 0), "Value must be positive.")
    .ValidateOnStart(); // Runs async validation during host startup.

using IHost host = builder.Build();
await host.StartAsync(); // Async validation completes here and seeds the validated instance.
// Later synchronous IOptions<MyOptions>.Value access returns the already-validated instance.

Type of breaking change

  • Behavioral change: Existing binaries might behave differently at run time. Synchronous access of an async-validated options type now throws OptionsValidationException instead of returning an unvalidated instance.
  • Source incompatible: IAsyncValidateOptions<TOptions> now derives from IValidateOptions<TOptions> and drops in TOptions contravariance. Types that implement the interface directly must add a Validate method, and code relying on the contravariant conversion might no longer compile.

A preview binary that implements IAsyncValidateOptions<TOptions> directly (only ValidateAsync) can also fail to load against the new assembly until recompiled, so the change is binary-incompatible in that narrow direct-implementer scenario. The common consumer scenario (registering validators through the built-in APIs) is a behavioral change only. All impact is scoped to code built against the .NET 11 Preview 6/7 async API.

Reason for change

Asynchronous options validation was originally approved (dotnet/runtime#128100) as a startup-only, independent async counterpart to IValidateOptions<TOptions>. Implementing post-startup revalidation exposed a correctness gap: Options have synchronous entry points (IOptions<T>.Value, IOptionsSnapshot<T>.Get, IOptionsMonitor<T>.Get, IOptionsFactory<T>.Create), and a validator that implemented only the async interface had no synchronous contract to run through. A value could therefore be returned and cached before any async validation ran, silently bypassing validation. Unifying IAsyncValidateOptions<TOptions> under IValidateOptions<TOptions> gives a single validator collection (preserving registration order and single invocation) and makes the previously silent gap explicit: Synchronous access of an async-validated type now fails fast with an actionable message. Because the async API had not yet reached a stable release, the contract was corrected before shipping. See the approved proposal dotnet/runtime#130719.

Recommended action

  • Call .ValidateOnStart() on the options builder and run async validation during host startup (await host.StartAsync()). This validates and seeds the initial IOptions<TOptions> / IOptionsMonitor<TOptions> instance so later synchronous access succeeds.
  • Avoid synchronously accessing (IOptions<T>.Value, IOptionsMonitor<T>.CurrentValue / .Get) an options type that has only asynchronous validators before startup has completed.
  • Be aware of the paths ValidateOnStart does not cover for async-validated types: IOptionsSnapshot<T> is validated synchronously per scope and is not seeded by startup validation, and IOptionsMonitor<T> recreation after a configuration change is also synchronous. Accessing those paths for an async-only-validated type still throws; keep at least one synchronous validator if you need those paths validated.
  • If you implement IAsyncValidateOptions<TOptions> directly, add the now-required synchronous Validate(string?, TOptions) method (return ValidateOptionsResult.Skip, or Fail to signal that synchronous validation is unsupported, mirroring the built-in AsyncValidateOptions<TOptions>), and recompile against .NET 11.
  • If you relied on the removed in TOptions contravariance of IAsyncValidateOptions<TOptions>, adjust the affected code.
  • There is no AppContext compatibility switch; the supported migration is ValidateOnStart plus completing startup.

Feature area

Extensions

Affected APIs

  • Microsoft.Extensions.Options.IAsyncValidateOptions<TOptions>—now derives from Microsoft.Extensions.Options.IValidateOptions<TOptions>; in TOptions contravariance removed.
  • Microsoft.Extensions.Options.AsyncValidateOptions<TOptions> and its dependency overloads AsyncValidateOptions<TOptions, TDep> through AsyncValidateOptions<TOptions, TDep1, TDep2, TDep3, TDep4, TDep5>—now expose ValidateOptionsResult Validate(string?, TOptions).
  • Synchronous options creation/access paths whose runtime behavior changes for async-validated types:
    • Microsoft.Extensions.Options.IOptions<TOptions>.Value
    • Microsoft.Extensions.Options.IOptionsMonitor<TOptions>.CurrentValue
    • Microsoft.Extensions.Options.IOptionsMonitor<TOptions>.Get(System.String)
    • Microsoft.Extensions.Options.IOptionsSnapshot<TOptions>.Value
    • Microsoft.Extensions.Options.IOptionsSnapshot<TOptions>.Get(System.String)
    • Microsoft.Extensions.Options.IOptionsFactory<TOptions>.Create(System.String)
    • Microsoft.Extensions.Options.OptionsFactory<TOptions>.Create(System.String)
  • Related registration APIs (async validators now register under IValidateOptions<TOptions>): Microsoft.Extensions.Options.OptionsBuilder<TOptions>.ValidateAsync(...) and Microsoft.Extensions.DependencyInjection.OptionsBuilderAsyncValidationExtensions.

Note

This issue was drafted with assistance from GitHub Copilot.


Associated WorkItem - 630670

Activity

  1. added
    🗺️ mapQUESTOnly used as a way to mark an issue as updated for quest. RepoMan should instantly remove it.
    on Sep 3, 2026
  2. removed
    🗺️ mapQUESTOnly used as a way to mark an issue as updated for quest. RepoMan should instantly remove it.
    on Sep 3, 2026
  3. added
    🗺️ reQUESTTriggers an issue to be imported into Quest.
    and removed
    breaking-changeIndicates a .NET Core breaking change
    on Sep 3, 2026
  4. added
    🗺️ mapQUESTOnly used as a way to mark an issue as updated for quest. RepoMan should instantly remove it.
    on Sep 3, 2026
  5. removed
    🗺️ mapQUESTOnly used as a way to mark an issue as updated for quest. RepoMan should instantly remove it.
    on Sep 3, 2026
  6. added
    📌 seQUESTeredIdentifies that an issue has been imported into Quest.
    and removed
    🗺️ reQUESTTriggers an issue to be imported into Quest.
    on Sep 3, 2026
  7. added
    🗺️ mapQUESTOnly used as a way to mark an issue as updated for quest. RepoMan should instantly remove it.
    on Sep 8, 2026
  8. moved this from 🔖 Ready to 🏗 In progress in dotnet/docs September 2026 sprint projecton Sep 8, 2026
  9. removed
    🗺️ mapQUESTOnly used as a way to mark an issue as updated for quest. RepoMan should instantly remove it.
    on Sep 8, 2026
  10. moved this from 🏗 In progress to 👀 In review in dotnet/docs September 2026 sprint projecton Sep 9, 2026
  11. added
    🗺️ mapQUESTOnly used as a way to mark an issue as updated for quest. RepoMan should instantly remove it.
    on Sep 9, 2026
  12. removed
    🗺️ mapQUESTOnly used as a way to mark an issue as updated for quest. RepoMan should instantly remove it.
    on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

📌 seQUESTeredIdentifies that an issue has been imported into Quest.

Type

No type

Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions