Skip to content

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

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

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