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 by making the existing DataAnnotationValidateOptions<T> async-aware: it now also implements IAsyncValidateOptions<T>, and ValidateDataAnnotations() registers the same instance for both sync and async validation. No new public types or extension methods are needed — a single ValidateDataAnnotations() call covers both sync per-access validation and async startup validation.

Notable consumer: .NET Aspire is a significant consumer of ValidateDataAnnotations() + ValidateOnStart(). Making the existing bridge async-aware directly benefits Aspire-hosted services that need I/O-bound configuration validation (e.g., connection string reachability checks) at startup — with zero code changes for existing consumers.

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

API Proposal

Microsoft.Extensions.Options.DataAnnotations

namespace Microsoft.Extensions.Options;

// Existing class — gains IAsyncValidateOptions<T>
public partial class DataAnnotationValidateOptions<TOptions> :
    IAsyncValidateOptions<TOptions>
{
    // implicitly implement the interface
}

The existing DataAnnotationValidateOptions<T> adds IAsyncValidateOptions<T> to its interface list. The ValidateAsync method is implicitly implemented — it mirrors the sync Validate() recursive walk but delegates to Validator.TryValidateObjectAsync, which evaluates both sync attributes (Phase 1) and AsyncValidationAttributes (Phase 2).

No new public types. No new extension methods. ValidateDataAnnotations() registers the same instance as both IValidateOptions<T> and IAsyncValidateOptions<T>.

API Usage

Scenario 1: Async DataAnnotations at startup (zero code changes)

Existing ValidateDataAnnotations() calls automatically gain async startup validation. Users with AsyncValidationAttribute-decorated types get async validation without any API change.

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 — UNCHANGED from existing code
builder.Services.AddOptions<TenantDatabaseSettings>()
    .Bind(builder.Configuration.GetSection("Database"))
    .ValidateDataAnnotations()   // now registers BOTH:
                                 //   - IValidateOptions<T>      → sync per-access
                                 //   - IAsyncValidateOptions<T>  → async startup
    .ValidateOnStart();

// Recommended: bound async validators with a startup timeout
builder.Services.Configure<HostOptions>(opts =>
    opts.StartupTimeout = TimeSpan.FromSeconds(30));

// What happens at startup (Host.StartAsync):
//   Stage 1 (sync): IStartupValidator.Validate()
//     → OptionsFactory.Create() → Validate() → Validator.TryValidateObject
//   Stage 2 (async): IAsyncStartupValidator.ValidateAsync(ct)
//     → ValidateAsync() → Validator.TryValidateObjectAsync
//       → sync attributes (Phase 1), then [AsyncConnectionStringValid] (Phase 2)
//     → OptionsValidationException if validation fails → app won't start
//
// After startup:
//   Validate() runs on every IOptions.Value / IOptionsSnapshot.Get() access ✅
//   ValidateAsync() does NOT run after startup (by design)

Scenario 2: Sync-only types — no behavioral change

For types with only sync attributes, the fold adds async infrastructure at startup but with negligible overhead (per API review board consensus):

public class SimpleSettings
{
    [Required]
    public string Name { get; set; } = "";

    [Range(1, 100)]
    public int MaxRetries { get; set; } = 3;
}

// Existing code — unchanged, still works identically
builder.Services.AddOptions<SimpleSettings>()
    .ValidateDataAnnotations()   // registers both interfaces (same instance)
    .ValidateOnStart();

// At startup:
//   Stage 1 (sync): [Required], [Range] validated ✅
//   Stage 2 (async): TryValidateObjectAsync runs sync attrs again — negligible overhead,
//     no AsyncValidationAttributes present → completes synchronously
// Per-access: Validate() runs as before ✅

Alternative Designs (considered and rejected)

A. Standalone async class + ValidateDataAnnotationsAsync() method

A new standalone class implementing only IAsyncValidateOptions<T>, with a new ValidateDataAnnotationsAsync() extension method. Users must call both methods for full coverage.

public class AsyncDataAnnotationValidateOptions<TOptions>
    : IAsyncValidateOptions<TOptions> where TOptions : class { ... }

// Usage — requires TWO calls
builder.Services.AddOptions<SmtpSettings>()
    .ValidateDataAnnotations()         // sync per-access
    .ValidateDataAnnotationsAsync()    // async startup
    .ValidateOnStart();

Rejected: Pit of failure — users who add AsyncValidationAttribute but forget ValidateDataAnnotationsAsync() get silent non-validation; two calls for the most common case.

B. Inheritance: AsyncDataAnnotationValidateOptions<T> : DataAnnotationValidateOptions<T>

A derived class that inherits sync validation and adds async, registered via a new ValidateDataAnnotationsAsync() method.

public class AsyncDataAnnotationValidateOptions<TOptions>
    : DataAnnotationValidateOptions<TOptions>, IAsyncValidateOptions<TOptions> { ... }

Rejected: Still requires discovering a new method; calling both ValidateDataAnnotations() + ValidateDataAnnotationsAsync() registers two IValidateOptions<T> entries causing double sync validation per access; introduces a new public class unnecessarily.

C. Fold into ValidateDataAnnotations() — chosen by API review

Adding IAsyncValidateOptions<T> to the existing DataAnnotationValidateOptions<T> class. The API review board concluded the startup overhead from double sync-attribute evaluation is negligibleValidator.TryValidateObjectAsync runs sync attributes in Phase 1 before async attributes in Phase 2, so they run both in Stage 1 (sync) and Stage 2 (async), but this is a cheap reflection pass that only happens once at startup.

Chosen because:

  • Zero API discovery needed — existing ValidateDataAnnotations() calls automatically gain async support
  • No new types or methods — minimal API surface change
  • No pit of failure — AsyncValidationAttribute on any type is always picked up
  • Sync per-access path is completely unchanged
  • Startup overhead is negligible per review board consensus

Cross-references

Risks

  • Startup timeout for I/O-bound validators — Async validators (e.g., database reachability checks) run at Host.StartAsync() with the CancellationToken from HostOptions.StartupTimeout. The default timeout is Timeout.InfiniteTimeSpan (no limit). Applications using I/O-bound async validators should configure a startup timeout:
    builder.Services.Configure<HostOptions>(opts =>
        opts.StartupTimeout = TimeSpan.FromSeconds(30));
  • CancellationToken propagation — The token flows: Host.StartAsync(ct) → linked CTS (ctApplicationStoppingStartupTimeout) → IAsyncStartupValidator.ValidateAsync(ct)DataAnnotationValidateOptions<T>.ValidateAsync(ct)Validator.TryValidateObjectAsync(..., ct). No gaps in propagation — async validators receive the combined token and can be cancelled by any of the three sources.

Metadata

Metadata

Assignees

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