Repository navigation
Description
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
AsyncValidationAttributewith ~20 lines of changes, and Minimal APIs inheriting async support with zero additional changes. Prior art: Theoroztocil/validation-demobranch indotnet/aspnetcoreprototypedAsyncValidationAttributeandIAsyncValidatableObjectinMicrosoft.Extensions.Validationto 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 implementingIAsyncValidateOptions<T>→ emitsValidateAsync()[OptionsValidator]on a type implementing onlyIValidateOptions<T>→ emitsValidate()only (unchanged)- Types with
IAsyncValidatableObject→ generatedValidateAsync()callsIAsyncValidatableObject.ValidateAsync()for self-validation - Types with
IValidatableObjectonly → generatedValidateAsync()falls back to syncIValidatableObject.Validate()
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 validatorsAlternative 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>.Valueis a property consumed by every ASP.NET Core app, middleware, and third-party library. It cannot be made async.lock+awaitis prohibited in C#, soUnnamedOptionsManagerwould need a complete rewrite.ConcurrentDictionary<Lazy<T>>has no asyncGetOrAdd, so the entire cache primitive would need replacement.- Every sync consumer of
OptionsFactorywould 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 | ❌ | |
| 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 whetherHost.StartAsync()has actually executed yet, so it cannot detect the case where options are resolved duringHost.StartAsync()but beforeIAsyncStartupValidatorruns (Gap 2). This was an intentional v1 trade-off to avoid lifecycle state tracking complexity. - Timing difference: Sync
ValidateOnStartvalidates duringOptionsFactory.Create()(inside the firstGet()call). AsyncValidateOnStartAsyncvalidates in a separate step afterCreate(). Both run duringHost.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()viaValidateOnStartAsync(). Runtime config changes throughIOptionsMonitor<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: AggregateException2. 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 complete3. 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
dotnet-policy-service commentedon May 12, 2026
Tagging subscribers to this area: @dotnet/area-extensions-options
See info in area-owners.md if you want to be subscribed.
svick commentedon May 13, 2026
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 throwNotSupportedException, because it callsIsValid()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()andValidateOnStartAsync()and have the sync methods deal with both sync and async validation?rosebyte commentedon May 14, 2026
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?
ViveliDuCh commentedon May 14, 2026
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).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
Clockwork-Muse commentedon May 14, 2026
Note that there are also proposals for async configuration providers as well.
But more generally, I would want async configuration/factories before validation.
tarekgh commentedon May 18, 2026
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
IAsyncValidatableObject.ValidateAsyncValueTask<IEnumerable<>>vsIAsyncEnumerable<>ValueTaskvsTaskfor all async validation methodsValueTaskbut not finalizedGetValidationResultAsyncplacement: on baseValidationAttributeor only onAsyncValidationAttribute?ValidationContext.Itemsthread safety under parallel executionConcurrentDictionary? Document as user responsibility?[Required]failure on Prop A skip async validators on Prop B?Must Resolve Before Shipping
ValidatorAPIs[DynamicallyAccessedMembers]parity with sync methodsAggregateException?ValidationException?[ValidateObjectMembers]on cyclic graphsSynchronizationContextdeadlock warning for the dual-mode (sync fallback) patternDesign Gaps Needing Follow-Up Proposals
IAsyncOptions<T>for lazy async resolution (or explicitly declare unsupported)IOptionsMonitorchange re-validation: async validators on config reloadEditContext.ValidateAsync, async event model)IProgress<>, partial results, "in progress" messaging)Ecosystem Risks to Document
IValidatableObject.Validate()throwing onIAsyncValidatableObjectimplementors: affects EF Core, serializers, any code calling the interfaceValidateDataAnnotationsAsync()withoutValidateOnStartAsync()silently skips all async validation: pit of failuresvick commentedon May 19, 2026
@tarekgh
If it behaves like this, I think that will be quite unexpected. If a user knows how
ValidateDataAnnotations()andValidate()behave, they will expectValidateDataAnnotationsAsync()andValidateAsync()to be comparable, justasync. 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 methodsStartOnlyValidateDataAnnotationsAsync()andStartOnlyValidateAsync(), or something like that?Clockwork-Muse commentedon May 19, 2026
... 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?tarekgh commentedon May 19, 2026
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:Every layer is synchronous. There is no hook to await async validators.
Approaches Considered
IOptionsMonitorentirely for any type with async-only attributesIOptionsMonitorasyncCurrentValueis a property, can't returnTask;ChangeToken.OnChangetakesAction(void)IAsyncOptionsMonitor<T>Recommendation for v1
Explicitly declare
IOptionsMonitorasync re-validation as out of scope.State it clearly in the proposal:
Rationale
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.ViveliDuCh commentedon May 19, 2026
@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
StartOnlyValidateDataAnnotationsAsyncwould 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'sValidateOnStart()that controls when sync validators run at startup. WithoutValidateOnStart(), sync validators only fire lazily whenIOptions<T>.Valueis first accessed:ValidateDataAnnotationsAsync()says "use DataAnnotations with async support". It's the async counterpart ofValidateDataAnnotations(), 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 withValidateOnStartAsync(), and that async validators run once duringHost.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 whenValidateDataAnnotationsAsync()is used withoutValidateOnStartAsync(), similar to how some analyzers warn about unawaited tasks.rosebyte commentedon May 20, 2026
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 propertyIOptions<T>.Valueand fromIOptionsMonitor<T>.Get(name)orIOptionsMonitor<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.
2 remaining items
ViveliDuCh commentedon May 22, 2026
Addressing the remaining feedback, status update on items 6-19:
Category 2: Must Resolve Before Shipping
[RequiresUnreferencedCode],[DynamicallyAccessedMembers],[UnconditionalSuppressMessage]on all async APIs matching sync counterpartsAggregateException; non-validation exceptions propagate.ConfigureAwait(false)everywhere. Bypass architecture prevents sync-over-async.IsValid()throwsInvalidOperationExceptionCategory 3: Design Gaps (Follow-Up Proposals)
IAsyncOptions<T>for lazy async resolutionIOptionsMonitorchange re-validationIOptionsMonitor.OnChange+Validator.TryValidateObjectAsyncAsyncValidationAttributethrowsInvalidOperationExceptionby designValidateOnStartAsync()runsCancellationToken, timeout policy is caller's responsibilityCategory 4: Ecosystem Risks
IValidatableObject.Validate()throws onIAsyncValidatableObjectInvalidOperationException; asyncValidatorchecksis IAsyncValidatableObjectfirstValidateOnStartAsync()AsyncValidationGuard<T>throwsInvalidOperationExceptionon.ValueaccessvalidateAllProperties=true, source generator sequential for 0-1 membersThanks again @tarekgh
KevinCathcart commentedon May 26, 2026
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.
bartonjs commentedon May 26, 2026
Video
Add async validation support for System.ComponentModel.DataAnnotations
Add async startup validation for Microsoft.Extensions.Options (#128788)
Add async startup validation for Microsoft.Extensions.Options (#128788)
Add async startup validation for Microsoft.Extensions.Options (dotnet…