Skip to content

[API Proposal]: Async Options Validation for Microsoft.Extensions.Options #128100

Closed

Description

@ViveliDuCh

Background and motivation

The Options validation pipeline (IValidateOptions<T> → DataAnnotationValidateOptions<T> → Validator.TryValidateObject) is entirely synchronous. With the proposed async Validator APIs in System.ComponentModel.DataAnnotations (companion proposal), the core validation engine can await I/O-bound validation, but the Options pipeline can't consume it because every layer is sync:

IOptions<T>.Value          ← property (can't be async)
  → OptionsFactory.Create() ← sync method
    → IValidateOptions<T>.Validate() ← sync interface
      → Validator.TryValidateObject() ← sync (would have async counterpart)

Concrete scenario: An ASP.NET Core app validates database connection strings at startup using ValidateOnStart(). Today, checking connectivity requires blocking a thread. With async DataAnnotations support (e.g., [AsyncConnectionStringValid]), the app needs an async startup validation path.

Strategy: Bypass, Don't Infect. Instead of making OptionsFactory.Create() async (which cascades into IOptions<T>.Value, a property that can't return Task), we create a parallel async pipeline that runs during Host.StartAsync(). The sync Create() path is untouched (zero breaking changes).

Relationship to Microsoft.Extensions.Validation: Microsoft.Extensions.Validation (Minimal API endpoint validation) and IAsyncValidateOptions<T> (Options startup validation) are complementary, not overlapping. The former validates request payloads at runtime; the latter validates configuration at startup. Both consume the same AsyncValidationAttribute / Validator.TryValidateObjectAsync() APIs from the companion proposal.

  • The aspnetcore prototype demonstrates M.E.Validation adopting AsyncValidationAttribute with ~20 lines of changes, and Minimal APIs inheriting async support with zero additional changes. Prior art: The oroztocil/validation-demo branch in dotnet/aspnetcore prototyped AsyncValidationAttribute and IAsyncValidatableObject in Microsoft.Extensions.Validation to prove the pipeline could handle async.

Notable consumer: .NET Aspire is a significant and growing consumer of ValidateDataAnnotations() + ValidateOnStart(). Any changes to Options.DataAnnotations or the Options validation source generator directly affect the Aspire developer experience.

Design doc: Halter's consolidated requirements gist

Related: dotnet/aspnetcore#46349

API Proposal

Microsoft.Extensions.Options

namespace Microsoft.Extensions.Options;

// New interface: async counterpart to IValidateOptions<T>
public partial interface IAsyncValidateOptions<in TOptions> where TOptions : class
{
    ValueTask<ValidateOptionsResult> ValidateAsync(
        string? name,
        TOptions options,
        CancellationToken cancellationToken = default);
}

// New interface: async counterpart to IStartupValidator
public partial interface IAsyncStartupValidator
{
    Task ValidateAsync(CancellationToken cancellationToken = default);
}

// New class: async lambda-based validator (0 dependencies)
public partial class AsyncValidateOptions<TOptions> : IAsyncValidateOptions<TOptions>
    where TOptions : class
{
    public AsyncValidateOptions(string? name,
        Func<TOptions, CancellationToken, ValueTask<bool>> validation,
        string failureMessage);
    public string? Name { get; }
    public Func<TOptions, CancellationToken, ValueTask<bool>> Validation { get; }
    public string FailureMessage { get; }
    public ValueTask<ValidateOptionsResult> ValidateAsync(
        string? name, TOptions options, CancellationToken cancellationToken = default);
}

// New class: async lambda-based validator (1 dependency)
public partial class AsyncValidateOptions<TOptions, TDep> : IAsyncValidateOptions<TOptions>
    where TOptions : class
{
    public AsyncValidateOptions(string? name, TDep dependency,
        Func<TOptions, TDep, CancellationToken, ValueTask<bool>> validation,
        string failureMessage);
    public string? Name { get; }
    public TDep Dependency { get; }
    public Func<TOptions, TDep, CancellationToken, ValueTask<bool>> Validation { get; }
    public string FailureMessage { get; }
    public ValueTask<ValidateOptionsResult> ValidateAsync(
        string? name, TOptions options, CancellationToken cancellationToken = default);
}

// ... AsyncValidateOptions<TOptions, TDep1, TDep2> through <TOptions, TDep1..TDep5>
// (same pattern as existing sync ValidateOptions<T, TDep1..TDep5>)

// Existing OptionsBuilderExtensions
public static partial class OptionsBuilderExtensions
{
    public static OptionsBuilder<TOptions> ValidateOnStart<TOptions>(this OptionsBuilder<TOptions> optionsBuilder);

    public static OptionsBuilder<TOptions> ValidateOnStartAsync<TOptions>(
        this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class;
}

// Infrastructure type for misuse protection (auto-registered as singleton).
// Not intended for direct use by application code.
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed partial class AsyncValidationState
{
    [EditorBrowsable(EditorBrowsableState.Never)]
    public bool StartupValidatorRegistered { get; set; }
}

// New extension methods for async lambda validation on OptionsBuilder<T>
public static partial class OptionsBuilderAsyncValidationExtensions
{
    // 0 dependencies
    public static OptionsBuilder<TOptions> ValidateAsync<TOptions>(
        this OptionsBuilder<TOptions> optionsBuilder,
        Func<TOptions, CancellationToken, ValueTask<bool>> validation,
        string failureMessage) where TOptions : class;

    // 1 dependency
    public static OptionsBuilder<TOptions> ValidateAsync<TOptions, TDep>(
        this OptionsBuilder<TOptions> optionsBuilder,
        Func<TOptions, TDep, CancellationToken, ValueTask<bool>> validation,
        string failureMessage) where TOptions : class where TDep : notnull;

    // ... up to 5 dependencies (same pattern as sync Validate<T, TDep1..TDep5>)
}

// On existing OptionsBuilder<TOptions>: guard registration used by ValidateAsync()
// and ValidateDataAnnotationsAsync(). Not intended for direct use.
public partial class OptionsBuilder<TOptions>
{
    [EditorBrowsable(EditorBrowsableState.Never)]
    public void RegisterAsyncValidationGuard();
}

Options Validation Source Generator

When the validator type explicitly implements IAsyncValidateOptions<T>, the [OptionsValidator] source generator emits a ValidateAsync() method alongside the existing sync Validate(). The generated code uses Validator.TryValidateValueAsync() per member and Task.WhenAll for types with 2+ validated members.

// Source generator emits this when the validator implements IAsyncValidateOptions<TOptions>:

// Generated ValidateAsync, parallel path (2+ members with validation attributes)
public async ValueTask<ValidateOptionsResult> ValidateAsync(
    string? name, MyOptions options, CancellationToken cancellationToken = default)
{
    ValidateOptionsResultBuilder? builder = null;

    // Per-member local async functions started concurrently
    var memberTasks = new Task<List<ValidationResult>?>[2];
    memberTasks[0] = ValidateMember_ConnectionStringAsync();
    memberTasks[1] = ValidateMember_TimeoutSecondsAsync();

    List<ValidationResult>?[] memberResults =
        await Task.WhenAll(memberTasks).ConfigureAwait(false);

    foreach (List<ValidationResult>? memberResult in memberResults)
    {
        if (memberResult is not null)
        {
            (builder ??= new()).AddResults(memberResult);
        }
    }

    // IAsyncValidatableObject self-validation (if model implements it)
    // context.MemberName = "ValidateAsync";
    // await foreach (var __result in ((IAsyncValidatableObject)options)
    //     .ValidateAsync(context, cancellationToken).ConfigureAwait(false))
    // {
    //     (builder ??= new()).AddResult(__result);
    // }

    return builder is null ? ValidateOptionsResult.Success : builder.Build();

    // Local async function per member (uses TryValidateValueAsync)
    async Task<List<ValidationResult>?> ValidateMember_ConnectionStringAsync()
    {
        var memberContext = new ValidationContext(options, "MyOptions", null, null);
        memberContext.MemberName = "ConnectionString";
        memberContext.DisplayName = string.IsNullOrEmpty(name)
            ? "ConnectionString" : $"{name}.ConnectionString";
        var validationResults = new List<ValidationResult>();
        var validationAttributes = new List<ValidationAttribute> { /* static instances */ };
        if (!await Validator.TryValidateValueAsync(
            options.ConnectionString, memberContext, validationResults,
            validationAttributes, cancellationToken).ConfigureAwait(false))
        {
            return validationResults;
        }
        return null;
    }

    // ... ValidateMember_TimeoutSecondsAsync() follows same pattern ...
}

// Generated ValidateAsync, sequential path (0 or 1 members)
// Uses the same TryValidateValueAsync but without Task.WhenAll overhead.

Source generator behavior:

  • [OptionsValidator] on a type implementing IAsyncValidateOptions<T> → emits ValidateAsync()
  • [OptionsValidator] on a type implementing only IValidateOptions<T> → emits Validate() only (unchanged)
  • Types with IAsyncValidatableObject → generated ValidateAsync() calls IAsyncValidatableObject.ValidateAsync() for self-validation
  • Types with IValidatableObject only → generated ValidateAsync() falls back to sync IValidatableObject.Validate()

Prototype: https://github.com/ViveliDuCh/runtime/tree/async-validation

API Usage

Scenario 2: Async lambda-based validation with DI

builder.Services.AddOptions<CloudInfoOptions>()
    .BindConfiguration("CloudInfo")
    .ValidateAsync<IStorageService>(async (opts, storageService, ct) =>
        await storageService.ExistsAsync(opts.Storage, ct),
        "Storage endpoint does not exist.")
    .ValidateOnStartAsync();

Scenario 3: Implementing IAsyncValidateOptions directly

For complex async validation with DI, implementing the interface directly is cleaner than a lambda:

public class CloudInfoValidator : IAsyncValidateOptions<CloudInfoOptions>
{
    private readonly IStorageService _storage;

    public CloudInfoValidator(IStorageService storage) => _storage = storage;

    public async ValueTask<ValidateOptionsResult> ValidateAsync(
        string? name, CloudInfoOptions options, CancellationToken ct)
    {
        if (!await _storage.ExistsAsync(options.Storage, ct))
            return ValidateOptionsResult.Fail(
                $"Storage '{options.Storage}' not found in '{options.Region}'.");
        return ValidateOptionsResult.Success;
    }
}

// Registration:
builder.Services.AddSingleton<IAsyncValidateOptions<CloudInfoOptions>, CloudInfoValidator>();
builder.Services.AddOptions<CloudInfoOptions>()
    .BindConfiguration("CloudInfo")
    .ValidateOnStartAsync();

Scenario 4: Mixed sync + async validation

Sync and async pipelines coexist. Sync validators run inside OptionsFactory.Create() as usual; async validators run during Host.StartAsync() via the bypass pipeline.

builder.Services.AddOptions<SmtpSettings>()
    .Bind(builder.Configuration.GetSection("Smtp"))
    .ValidateDataAnnotations()        // sync [Required], [Range], runs in Create()
    .Validate(opts => opts.Port > 0,  // sync lambda
        "Port must be positive.")
    .ValidateAsync(async (opts, ct) =>// async lambda
    {
        await Task.Delay(10, ct);
        return opts.Host != "localhost" || opts.Port != 25;
    }, "Default SMTP config not allowed in production.")
    .ValidateOnStart()                // triggers sync validators
    .ValidateOnStartAsync();          // triggers async validators

Alternative Designs

CreateAsync() approach: making OptionsFactory.Create() itself async

Instead of bypassing the factory, make the factory's validation step async. This would require:

File Change Difficulty
IOptionsFactory.cs New IAsyncOptionsFactory<T> interface with ValueTask<T> CreateAsync() Easy
OptionsFactory.cs Add CreateAsync(), new constructor param for IAsyncValidateOptions<T>[] Medium
UnnamedOptionsManager.cs Replace lock with SemaphoreSlim, add sync fallback Hard
OptionsManager.cs Add GetAsync(), deal with sync ConcurrentDictionary<Lazy<T>> cache Hard
OptionsMonitor.cs Add GetAsync(), async change notification Hard
OptionsCache.cs Need AsyncLazy<T> (doesn't exist in BCL) Hard
IOptions<T>.Value Property, can't return Task Impossible

Why not chosen:

  • IOptions<T>.Value is a property consumed by every ASP.NET Core app, middleware, and third-party library. It cannot be made async.
  • lock + await is prohibited in C#, so UnnamedOptionsManager would need a complete rewrite.
  • ConcurrentDictionary<Lazy<T>> has no async GetOrAdd, so the entire cache primitive would need replacement.
  • Every sync consumer of OptionsFactory would need a sync-over-async fallback (.GetAwaiter().GetResult()), creating deadlock risk in ASP.NET contexts.

The bypass approach avoids all of these problems by separating creation from validation. OptionsFactory.Create() runs normally (configure + post-configure, no async validators registered), and the async pipeline validates the resulting cached instance during Host.StartAsync().

Startup validation guard strategies

If async validators are registered but ValidateOnStartAsync() is not called (or there is no Generic Host), the guard throws InvalidOperationException on first .Value access instead of silently skipping validation. Several approaches for closing gaps in the async startup validation pipeline were explored. The strategy exploration summary documents five alternatives:

Approach Description Gap 1 (No Host) Gap 2 (Before Startup)
A1: Simple Guard ⭐ (chosen) AsyncValidationGuard<T> throws if ValidateOnStartAsync() was not called ✅ ❌
A2: Lifecycle Guard 3-phase state machine tracking startup progress ✅ ✅
B: Dual Registration Registers both async and sync-only fallback validators ⚠️ Partial ❌
C: Factory Awareness OptionsFactory checks async validation state in Create() ✅ ✅
D: Startup Ordering Interleaves sync and async validation per-type during startup ❌ ✅

Gap 1 (No Host): Developer registers .ValidateDataAnnotationsAsync() without Host, async validators silently never run.
Gap 2 (Before Startup): Code resolves IOptions<T>.Value during Host.StartAsync() before IAsyncStartupValidator runs, options cached without async validation.

Runtime re-validation (out of scope): IOptionsMonitor<T> runtime config changes execute only sync validators. This is an architectural constraint of the existing sync Options interfaces. Developers needing runtime async re-validation can subscribe to IOptionsMonitor.OnChange and call Validator.TryValidateObjectAsync directly outside the pipeline.

Risks

  • All additions are additive: new interfaces, new classes, new extension methods. No overload ambiguity.
  • Guard limitation (registration-time only): The guard checks whether ValidateOnStartAsync() was called during service registration. It does not track whether Host.StartAsync() has actually executed yet, so it cannot detect the case where options are resolved during Host.StartAsync() but before IAsyncStartupValidator runs (Gap 2). This was an intentional v1 trade-off to avoid lifecycle state tracking complexity.
  • Timing difference: Sync ValidateOnStart validates during OptionsFactory.Create() (inside the first Get() call). Async ValidateOnStartAsync validates in a separate step after Create(). Both run during Host.StartAsync() before any request: the end user behavior is identical (invalid config → app crashes at startup).
  • Parallel execution: All async validation runs in parallel at multiple levels (see below). Validators must not rely on execution order and must be safe for concurrent invocation.
  • Startup-only constraint: Async validators run once during Host.StartAsync() via ValidateOnStartAsync(). Runtime config changes through IOptionsMonitor<T> execute only sync validators. This is an architectural constraint of the existing Options interfaces, not a v1 trade-off.

Parallel Execution Model

The async Options validation pipeline executes validators in parallel at four levels to maximize throughput for I/O-bound validators:

1. Cross-options-type parallelism (AsyncStartupValidator.ValidateAsync)

When multiple options types are registered with ValidateOnStartAsync(), all are validated concurrently:

// These two options types validate in parallel at startup
services.AddOptions<DatabaseSettings>()
    .ValidateDataAnnotationsAsync()
    .ValidateOnStartAsync();

services.AddOptions<CacheSettings>()
    .ValidateDataAnnotationsAsync()
    .ValidateOnStartAsync();

// Host.StartAsync() → IAsyncStartupValidator.ValidateAsync()
//   → DatabaseSettings validation ─┐
//   → CacheSettings validation  ───┤── run concurrently
//   → collect all OptionsValidationExceptions
//   → single exception: rethrow; multiple: AggregateException

2. Cross-validator parallelism (ValidateOnStartAsync lambda)

When multiple IAsyncValidateOptions<T> validators are registered for the same options type (via .ValidateAsync() lambdas, IAsyncValidateOptions<T> implementations, or ValidateDataAnnotationsAsync()), all validators run concurrently:

services.AddOptions<CloudInfoOptions>()
    .ValidateAsync(async (opts, ct) =>
        await CheckStorageAsync(opts.Storage, ct), "Storage not found")
    .ValidateAsync(async (opts, ct) =>
        await CheckRegionAsync(opts.Region, ct), "Region not found")
    .ValidateOnStartAsync();

// Both validators start concurrently via Task.WhenAll
// All failures collected after both complete

3. Source generator parallelism (Options validation source generator)

When the Options validation source generator emits code for types implementing IAsyncValidateOptions<T> with 2+ members having validation attributes, the generated ValidateAsync method validates members in parallel using per-member local async functions and Task.WhenAll:

// Generated code (simplified):
public async ValueTask<ValidateOptionsResult> ValidateAsync(
    string? name, MyOptions options, CancellationToken cancellationToken)
{
    var memberTasks = new Task<List<ValidationResult>?>[2];
    memberTasks[0] = ValidateMember_ConnectionStringAsync();
    memberTasks[1] = ValidateMember_TimeoutAsync();

    var results = await Task.WhenAll(memberTasks).ConfigureAwait(false);
    // ... collect results ...
}

For types with 0 or 1 validated members, the sequential path is preserved (no parallelization overhead).

Behavioral changes from parallelism

Aspect Before (Sequential) After (Parallel)
Validator execution order Deterministic (registration order) Non-deterministic
Failure collection All failures collected (no short-circuit) All failures collected ✅ same
Unexpected exceptions Stops at first throw All validators run; exceptions collected after
Cancellation Each validator gets the same token Same ✅
Thread safety Single-threaded within pipeline Validators must be safe for concurrent execution

Activity

  1. added
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on May 12, 2026
  2. self-assigned this
    on May 12, 2026
  3. added this to the 11.0.0 milestone on May 12, 2026
  4. dotnet-policy-service commented on May 12, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-extensions-options
    See info in area-owners.md if you want to be subscribed.

  5. svick commented on May 13, 2026

    @svick
    Member

    How will this behave if I have both sync and async validation attributes on the same type (basically scenario 4 above)? Wouldn't the sync pipeline call Validator.TryValidateObject(), which would throw NotSupportedException, because it calls IsValid() on the async attributes?


    I don't like much that the implementation detail of having separate sync and async pipelines needs to be understood by the user. Specifically, couldn't we remove ValidateDataAnnotationsAsync() and ValidateOnStartAsync() and have the sync methods deal with both sync and async validation?

  6. rosebyte commented on May 14, 2026

    @rosebyte
    Member

    I don't see how scenario 2 should work, is it only validated on host start or it will validate any configuration changes that happen on the fly? I see you mention parallel execution, however, I don't see how it then lands back to the synchronous flow the options currently use for change handling, could you please elaborate?

  7. ViveliDuCh commented on May 14, 2026

    @ViveliDuCh
    MemberAuthor

    How will this behave if I have both sync and async validation attributes on the same type (basically scenario 4 above)?

    It depends on the attribute design. If an AsyncValidationAttribute subclass only overrides IsValidAsync() (the default), the sync pipeline will throw InvalidOperationException (intended behavior). Here, there's a sample that elaborates further on scenario 4 (dual-mode attribute pattern).

    couldn't we remove ValidateDataAnnotationsAsync() and ValidateOnStartAsync() and have the sync methods deal with both sync and async validation?

    No, full unification is impossible since IOptions.Value is a property and can't return Task. But the two pipelines can be hidden behind the existing API method names so developers never see the split. I'll add the strategy options. Thanks! @svick

  8. Clockwork-Muse commented on May 14, 2026

    @Clockwork-Muse
    Contributor

    Note that there are also proposals for async configuration providers as well.

    But more generally, I would want async configuration/factories before validation.

  9. tarekgh commented on May 18, 2026

    @tarekgh
    Member

    I have been looking at proposals, and below is a summary of the issues that were raised, including some of my own observations. I believe these points are important and should be addressed explicitly in the proposal.
    I am inclining to have us state explicitly that async validation only runs at startup (ValidateOnStartAsync). Runtime config changes use only sync validators. Async attributes on options classes are validated once at boot. This may simplify our async v1 options validation.

    Issues: #128096 (Core APIs) | #128100 (Options Pipeline)


    Must Resolve Before API Approval

    # Item Options
    1 Return type of IAsyncValidatableObject.ValidateAsync ValueTask<IEnumerable<>> vs IAsyncEnumerable<>
    2 ValueTask vs Task for all async validation methods Leaning ValueTask but not finalized
    3 GetValidationResultAsync placement: on base ValidationAttribute or only on AsyncValidationAttribute? @halter73 gist says base class; proposal says subclass only
    4 ValidationContext.Items thread safety under parallel execution Needs spec: copy-per-property? ConcurrentDictionary? Document as user responsibility?
    5 Cross-property short-circuit semantics: does [Required] failure on Prop A skip async validators on Prop B? Needs explicit specification

    Must Resolve Before Shipping

    # Item Notes
    6 Trimming/AOT annotations on new async Validator APIs Need [DynamicallyAccessedMembers] parity with sync methods
    7 Exception aggregation model: what type wraps multiple validator exceptions in parallel? AggregateException? ValidationException?
    8 Recursion depth / unbounded parallelism with [ValidateObjectMembers] on cyclic graphs Visited-set snapshot may not catch cycles before task explosion
    9 SynchronizationContext deadlock warning for the dual-mode (sync fallback) pattern sync-over-async, at minimum: document; ideally: detect and warn

    Design Gaps Needing Follow-Up Proposals

    # Item Owner/Tracker
    10 IAsyncOptions<T> for lazy async resolution (or explicitly declare unsupported) Raised by @tarekgh, no decision
    11 Options IOptionsMonitor change re-validation: async validators on config reload Asked by @rosebyte, unanswered. Should we say not support async?
    12 Blazor async validation API (EditContext.ValidateAsync, async event model) @halter73 gist Phase 3, no proposal filed
    13 MVC async validation (large scope, deferred) @halter73 gist Phase 4
    14 Progressive/streaming validation UX (IProgress<>, partial results, "in progress" messaging) Acknowledged in "Next Steps"
    15 Async configuration providers (#79193) relationship/ordering Raised by @Clockwork-Muse
    16 Timeout/circuit-breaker for I/O-bound async validators Not discussed, practical necessity

    Ecosystem Risks to Document

    # Risk
    17 IValidatableObject.Validate() throwing on IAsyncValidatableObject implementors: affects EF Core, serializers, any code calling the interface
    18 ValidateDataAnnotationsAsync() without ValidateOnStartAsync() silently skips all async validation: pit of failure
    19 Allocation burst from parallelism on objects with many properties: no guidance on when it's net-negative
  10. svick commented on May 19, 2026

    @svick
    Member

    @tarekgh

    I am inclining to have us state explicitly that async validation only runs at startup (ValidateOnStartAsync). Runtime config changes use only sync validators. Async attributes on options classes are validated once at boot.

    If it behaves like this, I think that will be quite unexpected. If a user knows how ValidateDataAnnotations() and Validate() behave, they will expect ValidateDataAnnotationsAsync() and ValidateAsync() to be comparable, just async. I think this is too big of a departure to be delegated to just the documentation, so: Is there a good way to indicate the difference in the API? Maybe naming the methods StartOnlyValidateDataAnnotationsAsync() and StartOnlyValidateAsync(), or something like that?

  11. Clockwork-Muse commented on May 19, 2026

    @Clockwork-Muse
    Contributor

    Maybe naming the methods

    ... I mean, that would make IOptionsMonitor/IOptionsSnapshot "unvalidated" on reload, so you'd need an additional method to make sure they're validated, at which point why wouldn't you use the always-validate one anyways?

  12. tarekgh commented on May 19, 2026

    @tarekgh
    Member

    There is a challenge in supporting IOptionsMonitor with async. This is why I was asking if we can skip supporting it with the async at least in async v1 version.

    IOptionsMonitor<T> triggers config reload through a fully synchronous chain:

    Configuration Change
      → ChangeToken.OnChange (Action callback — void, can't return Task)
        → OptionsMonitor.InvokeChanged (private void)
          → _cache.TryRemove(name)
          → Get(name)
            → _factory.Create(name) → sync IValidateOptions<T>.Validate()
          → _onChange?.Invoke(options, name) — Action<TOptions, string>
    

    Every layer is synchronous. There is no hook to await async validators.

    Approaches Considered

    Approach Why It Fails
    Sync fallback on reload Sync-over-async → deadlock/thread pool starvation
    Skip async attrs on reload Silent under-validation: object appears valid when it may not be
    Throw on reload Breaks IOptionsMonitor entirely for any type with async-only attributes
    Make IOptionsMonitor async CurrentValue is a property, can't return Task; ChangeToken.OnChange takes Action (void)
    New IAsyncOptionsMonitor<T> Massive scope: new interface, new DI registrations, all consumers must migrate

    Recommendation for v1

    Explicitly declare IOptionsMonitor async re-validation as out of scope.

    State it clearly in the proposal:

    Async validation of options runs at startup via ValidateOnStartAsync(). Runtime config
    changes through IOptionsMonitor<T> execute only sync IValidateOptions<T> validators.
    Options classes that use IOptionsMonitor<T> with async-only validation attributes should
    pair ValidateDataAnnotationsAsync() with ValidateDataAnnotations() to ensure reload
    validation covers sync attributes.

    Rationale

    • This is honest and avoids unsafe patterns (no sync-over-async recommendation).
    • Keeps the v1 scope manageable.
    • A future IAsyncOptionsMonitor<T> can be designed properly alongside the async configuration providers proposal (#79193), both share the same fundamental challenge of making the options/config infrastructure async-aware.
  13. ViveliDuCh commented on May 19, 2026

    @ViveliDuCh
    MemberAuthor

    @svick Thanks for raising this. It's a fair concern about discoverability.

    This was discussed and the startup-only behavior is an intentional, agreed-upon design constraint for v1: async validation only runs at startup (ValidateOnStartAsync), options classes are validated once at boot; runtime config changes use only sync validators.

    Renaming to StartOnlyValidateDataAnnotationsAsync would be inconsistent. Plus, the naming concern is already addressed by the execution-side API, not the registration-side API.

    This mirrors the existing sync pattern: ValidateDataAnnotations() doesn't specify timing either, it's ValidateOnStart() that controls when sync validators run at startup. Without ValidateOnStart(), sync validators only fire lazily when IOptions<T>.Value is first accessed:

    builder.Services.AddOptions<MyOptions>()
        .ValidateDataAnnotationsAsync()   // ← registers an IAsyncValidateOptions<T> validator
        .ValidateOnStartAsync();          // ← tells the host WHEN to run it
    • ValidateDataAnnotationsAsync() says "use DataAnnotations with async support". It's the async counterpart of ValidateDataAnnotations(), and both register a validator into DI. Neither one specifies when validation runs.
    • ValidateOnStartAsync() is the execution method that says "run async validators at startup". The "OnStart" in the name already makes the timing explicit.

    Documentation will make the pairing explicit. The XML docs and conceptual docs will clearly state that ValidateDataAnnotationsAsync() should be paired with ValidateOnStartAsync(), and that async validators run once during Host.StartAsync(). The Risks section of the proposal already calls this out. If there's still concern about discoverability, we could consider an analyzer or build warning when ValidateDataAnnotationsAsync() is used without ValidateOnStartAsync(), similar to how some analyzers warn about unawaited tasks.

  14. rosebyte commented on May 20, 2026

    @rosebyte
    Member

    The proposal positions the start-only scope as a v1 trade-off that a future version could broaden, but my concern is that no future version can. OptionsFactory.Create() is invoked from a property IOptions<T>.Value and from IOptionsMonitor<T>.Get(name) or IOptionsMonitor<T>.CurrentValue, all synchronous members returning just T.

    This split is more than just API cosmetics - configuration may change on the fly, and having two classes of validator with materially different reload semantics is going to be astonishing and potentially harmful for users. The things people will reach for ValidateAsync for (probing a database, an endpoint, a secret store) are exactly the things most likely to flip from valid to invalid at runtime, which is precisely where the proposed design stops validating.

  15. 2 remaining items

  16. ViveliDuCh commented on May 22, 2026

    @ViveliDuCh
    MemberAuthor

    Addressing the remaining feedback, status update on items 6-19:

    Category 2: Must Resolve Before Shipping

    # Item Status How
    6 Trimming/AOT annotations Resolved Full parity: [RequiresUnreferencedCode], [DynamicallyAccessedMembers], [UnconditionalSuppressMessage] on all async APIs matching sync counterparts
    7 Exception aggregation Resolved Single → rethrow; multiple → AggregateException; non-validation exceptions propagate
    8 Recursion depth Resolved Same visited-set cycle detection as sync path. No depth limit in either — consistent, not async-specific
    9 SyncContext deadlock Resolved .ConfigureAwait(false) everywhere. Bypass architecture prevents sync-over-async. IsValid() throws InvalidOperationException

    Category 3: Design Gaps (Follow-Up Proposals)

    # Item Status
    10 IAsyncOptions<T> for lazy async resolution Architectural constraint. Follow-up proposal needed.
    11 IOptionsMonitor change re-validation Yes, explicitly deferred. Workaround is IOptionsMonitor.OnChange + Validator.TryValidateObjectAsync
    12 Blazor async validation ✅ Shipped in .NET 11 preview 5: aspnetcore#66381 closed as completed in PR #66526
    13 MVC async validation Ditto, explicitly deferred. Sync MVC encountering AsyncValidationAttribute throws InvalidOperationException by design
    14 Progressive/streaming validation UX Ditto, further UX APIs deferred
    15 Async configuration providers relationship APIs are independent, but async validation assumes config is fully loaded before ValidateOnStartAsync() runs
    16 Timeout/circuit-breaker for I/O-bound validators Validators receive CancellationToken, timeout policy is caller's responsibility

    Category 4: Ecosystem Risks

    # Item Status
    17 IValidatableObject.Validate() throws on IAsyncValidatableObject ✅ Resolved: DIM throws InvalidOperationException; async Validator checks is IAsyncValidatableObject first
    18 Silent skip without ValidateOnStartAsync() ✅ Resolved: AsyncValidationGuard<T> throws InvalidOperationException on .Value access
    19 Allocation burst from parallelism ✅ Resolved: sync-first gating, parallel only when validateAllProperties=true, source generator sequential for 0-1 members

    Thanks again @tarekgh

  17. added
    blockingMarks issues that we want to fast track in order to unblock other important work
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    and removed
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on May 22, 2026
  18. KevinCathcart commented on May 26, 2026

    @KevinCathcart
    Contributor

    Out of curiosity, why does AsyncValidationState need to be public instead of an internal singleton? If the answer is that it should be public so that if the user is reinplementing the interfaces (which AFAIK is fully supported if the same semantics are upheld) they can also ensure the validation happened, then that makes sense.

    The no dynamic validation feels unfortunate, but I do agree the existing design would not work right with them.

    In theory asynchronous configure and validation could be supported on change if the options were eagerly asynchronously constructed on change token, with the last step being inserting directly into the IOptionsMonitorCache (instead of the current clear on change, and lazily re-construct). But I can't actually see any reasonable way to support ansynchronous configure for startup without doing sync over async, due to the synchronous interface design, and jumping through such hoops to support async validation without supporting async configure feels silly to me.

  19. bartonjs commented on May 26, 2026

    @bartonjs
    Member

    Video

    • Changed ValueTask to Task to match what we did for the related issue
    • Decided it's correct that IAsyncValidateOptions does not extend IValidateOptions
    • OptionsBuilderExtensions, we cut ValidateOnStartAsync. ValidateOnStart will just prefer async. If we need a "prefer sync" mode, we can add a bool-overload later.
    • Removed the EB-Never AsyncValidationState type and related machinery
    • Moved the OptionsBuilder async validation registration methods to just be instance methods on OptionsBuilder.
    namespace Microsoft.Extensions.Options;
    
    // New interface: async counterpart to IValidateOptions<T>
    public interface IAsyncValidateOptions<in TOptions> where TOptions : class
    {
        Task<ValidateOptionsResult> ValidateAsync(
            string? name,
            TOptions options,
            CancellationToken cancellationToken = default);
    }
    
    // New interface: async counterpart to IStartupValidator
    public interface IAsyncStartupValidator
    {
        Task ValidateAsync(CancellationToken cancellationToken = default);
    }
    
    // New class: async lambda-based validator (0 dependencies)
    public class AsyncValidateOptions<TOptions> : IAsyncValidateOptions<TOptions>
        where TOptions : class
    {
        public AsyncValidateOptions(string? name,
            Func<TOptions, CancellationToken, Task<bool>> validation,
            string failureMessage);
        public string? Name { get; }
        public Func<TOptions, CancellationToken, Task<bool>> Validation { get; }
        public string FailureMessage { get; }
        public Task<ValidateOptionsResult> ValidateAsync(
            string? name, TOptions options, CancellationToken cancellationToken = default);
    }
    
    // New class: async lambda-based validator (1 dependency)
    public class AsyncValidateOptions<TOptions, TDep> : IAsyncValidateOptions<TOptions>
        where TOptions : class
    {
        public AsyncValidateOptions(string? name, TDep dependency,
            Func<TOptions, TDep, CancellationToken, Task<bool>> validation,
            string failureMessage);
        public string? Name { get; }
        public TDep Dependency { get; }
        public Func<TOptions, TDep, CancellationToken, Task<bool>> Validation { get; }
        public string FailureMessage { get; }
        public Task<ValidateOptionsResult> ValidateAsync(
            string? name, TOptions options, CancellationToken cancellationToken = default);
    }
    
    // ... AsyncValidateOptions<TOptions, TDep1, TDep2> through <TOptions, TDep1..TDep5>
    // (same pattern as existing sync ValidateOptions<T, TDep1..TDep5>)
    
    public partial class OptionsBuilder<TOptions>
    {
        public virtual OptionsBuilder<TOptions> Validate(
            Func<TOptions, CancellationToken, Task<bool>> validation,
            string failureMessage);
    
        // 1 dependency
        public virtual OptionsBuilder<TOptions> Validate<TDep>(
            Func<TOptions, TDep, CancellationToken, Task<bool>> validation,
            string failureMessage) where TDep : notnull;
    
        // ... up to 5 dependencies (same pattern as sync Validate<T, TDep1..TDep5>)
    }
  20. added
    api-approvedAPI was approved in API review, it can be implemented
    and removed
    blockingMarks issues that we want to fast track in order to unblock other important work
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on May 26, 2026
  21. locked and limited conversation to collaborators on Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions