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 negligible — Validator.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 (ct ∪ ApplicationStopping ∪ StartupTimeout) → 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.
Background and motivation
Microsoft.Extensions.Optionsvalidation integrates withSystem.ComponentModel.DataAnnotationsthroughDataAnnotationValidateOptions<T>andValidateDataAnnotations(), which wireValidator.TryValidateObjectinto 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, andValidator.TryValidateObjectAsynctoSystem.ComponentModel.DataAnnotations. #128100 added the async Options pipeline —IAsyncValidateOptions<T>,AsyncValidateOptions<T,...>, andValidateOnStartasync execution — toMicrosoft.Extensions.Options.This proposal completes the bridge by making the existing
DataAnnotationValidateOptions<T>async-aware: it now also implementsIAsyncValidateOptions<T>, andValidateDataAnnotations()registers the same instance for both sync and async validation. No new public types or extension methods are needed — a singleValidateDataAnnotations()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.DataAnnotationsThe existing
DataAnnotationValidateOptions<T>addsIAsyncValidateOptions<T>to its interface list. TheValidateAsyncmethod is implicitly implemented — it mirrors the syncValidate()recursive walk but delegates toValidator.TryValidateObjectAsync, which evaluates both sync attributes (Phase 1) andAsyncValidationAttributes (Phase 2).No new public types. No new extension methods.
ValidateDataAnnotations()registers the same instance as bothIValidateOptions<T>andIAsyncValidateOptions<T>.API Usage
Scenario 1: Async DataAnnotations at startup (zero code changes)
Existing
ValidateDataAnnotations()calls automatically gain async startup validation. Users withAsyncValidationAttribute-decorated types get async validation without any API change.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):
Alternative Designs (considered and rejected)
A. Standalone async class +
ValidateDataAnnotationsAsync()methodA new standalone class implementing only
IAsyncValidateOptions<T>, with a newValidateDataAnnotationsAsync()extension method. Users must call both methods for full coverage.Rejected: Pit of failure — users who add
AsyncValidationAttributebut forgetValidateDataAnnotationsAsync()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.Rejected: Still requires discovering a new method; calling both
ValidateDataAnnotations()+ValidateDataAnnotationsAsync()registers twoIValidateOptions<T>entries causing double sync validation per access; introduces a new public class unnecessarily.C. Fold into
ValidateDataAnnotations()— chosen by API reviewAdding
IAsyncValidateOptions<T>to the existingDataAnnotationValidateOptions<T>class. The API review board concluded the startup overhead from double sync-attribute evaluation is negligible —Validator.TryValidateObjectAsyncruns 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:
ValidateDataAnnotations()calls automatically gain async supportAsyncValidationAttributeon any type is always picked upCross-references
AsyncValidationAttribute,IAsyncValidatableObject,Validator.TryValidateObjectAsync(approved, merged Add async validation support for System.ComponentModel.DataAnnotations #128656)IAsyncValidateOptions<T>,AsyncValidateOptions<T,...>,ValidateOnStartasync pipeline (approved)Risks
Host.StartAsync()with theCancellationTokenfromHostOptions.StartupTimeout. The default timeout isTimeout.InfiniteTimeSpan(no limit). Applications using I/O-bound async validators should configure a startup timeout:Host.StartAsync(ct)→ linked CTS (ct∪ApplicationStopping∪StartupTimeout) →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.