Skip to content

[API Proposal]: Async DataAnnotations bridge for Microsoft.Extensions.Options #129056

@ViveliDuCh

Description

@ViveliDuCh

Background and motivation

Microsoft.Extensions.Options validation integrates with System.ComponentModel.DataAnnotations through DataAnnotationValidateOptions<T> and ValidateDataAnnotations(), which wire Validator.TryValidateObject into the Options pipeline. This is the most common way .NET Aspire, ASP.NET Core, and other consumers apply DataAnnotations-based validation to configuration types at startup.

#128096 added AsyncValidationAttribute, IAsyncValidatableObject, and Validator.TryValidateObjectAsync to System.ComponentModel.DataAnnotations. #128100 added the async Options pipeline — IAsyncValidateOptions<T>, AsyncValidateOptions<T,...>, and ValidateOnStart async execution — to Microsoft.Extensions.Options.

This proposal completes the bridge: the Microsoft.Extensions.Options.DataAnnotations convenience layer that connects the two. Without DataAnnotationValidateOptionsAsync<T> and ValidateDataAnnotationsAsync(), users who decorate configuration types with AsyncValidationAttribute attributes have no ergonomic path to wire them into Options startup validation. They would need to manually implement IAsyncValidateOptions<T> and call Validator.TryValidateObjectAsync directly — exactly the boilerplate the existing ValidateDataAnnotations() convenience eliminates on the sync side.

Notable consumer: .NET Aspire is a significant consumer of ValidateDataAnnotations() + ValidateOnStart(). The async counterparts directly benefit Aspire-hosted services that need I/O-bound configuration validation (e.g., connection string reachability checks) at startup.

Related: dotnet/runtime#128096, dotnet/runtime#128100, dotnet/aspnetcore#46349

API Proposal

Microsoft.Extensions.Options.DataAnnotations

namespace Microsoft.Extensions.Options;

// New class: async counterpart to DataAnnotationValidateOptions<T>
public class DataAnnotationValidateOptionsAsync<TOptions>
    : IAsyncValidateOptions<TOptions> where TOptions : class
{
    [RequiresUnreferencedCode("Uses DataAnnotationValidateOptionsAsync which is unsafe given that " +
        "the options type passed in when calling ValidateAsync cannot be statically analyzed so its " +
        "members may be trimmed.")]
    public DataAnnotationValidateOptionsAsync(string? name);

    public string? Name { get; }

    public Task<ValidateOptionsResult> ValidateAsync(
        string? name,
        TOptions options,
        CancellationToken cancellationToken = default);
}
namespace Microsoft.Extensions.DependencyInjection;

// Existing class — new method added
public static class OptionsBuilderDataAnnotationsExtensions
{
    // Existing — unchanged
    [RequiresUnreferencedCode("...")]
    public static OptionsBuilder<TOptions> ValidateDataAnnotations<TOptions>(
        this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class;

    // New: async counterpart to ValidateDataAnnotations()
    [RequiresUnreferencedCode("Uses DataAnnotationValidateOptionsAsync which is unsafe given that " +
        "the options type passed in when calling ValidateAsync cannot be statically analyzed so its " +
        "members may be trimmed.")]
    public static OptionsBuilder<TOptions> ValidateDataAnnotationsAsync<[DynamicallyAccessedMembers(
        DynamicallyAccessedMemberTypes.PublicProperties |
        DynamicallyAccessedMemberTypes.NonPublicProperties)] TOptions>(
        this OptionsBuilder<TOptions> optionsBuilder) where TOptions : class;
}

API Usage

Scenario 1: Async DataAnnotations at startup

public class TenantDatabaseSettings
{
    [Required]
    public string TenantName { get; set; } = "";

    [Required]
    [AsyncConnectionStringValid] // AsyncValidationAttribute: tests DB connectivity
    public string ConnectionString { get; set; } = "";

    [Range(1, 300)]
    public int CommandTimeoutSeconds { get; set; } = 60;
}

// Program.cs
builder.Services.AddOptions<TenantDatabaseSettings>()
    .Bind(builder.Configuration.GetSection("Database"))
    .ValidateDataAnnotationsAsync()   // registers DataAnnotationValidateOptionsAsync<T>
    .ValidateOnStart();               // runs async validators at Host.StartAsync()

// What happens at startup:
// Host.StartAsync()
//   → IAsyncStartupValidator.ValidateAsync(ct)
//     → DataAnnotationValidateOptionsAsync<TenantDatabaseSettings>.ValidateAsync()
//       → Validator.TryValidateObjectAsync(settings, ctx, results, true, ct)
//         → [Required], [Range] run synchronously (Phase 1)
//         → [AsyncConnectionStringValid] runs asynchronously (Phase 2)
//       → OptionsValidationException if validation fails → app won't start

Scenario 2: Mixed sync + async DataAnnotations

Sync and async pipelines coexist. Sync validators run inside OptionsFactory.Create() on every IOptions<T>.Value access; async validators run once during Host.StartAsync().

builder.Services.AddOptions<SmtpSettings>()
    .Bind(builder.Configuration.GetSection("Smtp"))
    .ValidateDataAnnotations()        // sync [Required], [Range] — runs in Create()
    .ValidateDataAnnotationsAsync()   // async [AsyncSmtpReachable] — runs at startup
    .ValidateOnStart();

Alternative Designs

Fold into ValidateDataAnnotations()

Merging async registration into the existing sync method was considered and rejected: every existing call to ValidateDataAnnotations() would silently register async startup infrastructure even on options types with zero AsyncValidationAttribute instances — adding a reflection walk at startup for all existing users. The explicit opt-in model (ValidateDataAnnotationsAsync() as a separate call) is consistent with how the rest of #128100 is scoped.

Cross-references

Risks

No response

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions