Background and motivation
IAsyncValidateOptions<TOptions> was approved in #128100 as an independent async counterpart to IValidateOptions<TOptions>. At that time, async options validation was designed primarily for startup and runtime revalidation through IOptionsMonitor<TOptions> was explicitly out of scope.
Implementing post-startup revalidation exposed a correctness gap. Options still have synchronous entry points, including IOptions<TOptions>.Value, IOptionsSnapshot<TOptions>.Get, IOptionsMonitor<TOptions>.Get, and IOptionsFactory<TOptions>.Create. If a validator implements only the async interface, these paths have no synchronous contract through which to invoke it or determine whether it applies to the requested options name. A newly created value can therefore be returned without running every validator registered for its options type.
This can happen before startup as well as during reload. Resolving options before the async startup validator runs currently enters the synchronous pipeline, which does not see an IAsyncValidateOptions<TOptions> registration. The value can be returned and cached before the async validator has run at all.
The sibling async DataAnnotations APIs make the synchronous contract explicit: IAsyncValidatableObject inherits IValidatableObject, while AsyncValidationAttribute inherits ValidationAttribute and requires a synchronous implementation or an explicit failure when synchronous validation is unsupported. DataAnnotationValidateOptions<TOptions> likewise already implements both options-validation interfaces.
IAsyncValidateOptions<TOptions> should follow the same model. Its inherited Validate implementation can perform equivalent synchronous validation, return Skip when the validator does not apply, or conservatively fail when synchronous validation is unsupported. It must not block on ValidateAsync.
This also enables a single DI registration contract. All validators, including async-capable validators, are registered as IValidateOptions<TOptions>. Synchronous paths enumerate that collection and call Validate. Async paths enumerate the same collection and call ValidateAsync instead when a validator also implements IAsyncValidateOptions<TOptions>. IAsyncValidateOptions<TOptions> becomes a capability interface rather than a second validator collection.
Using one collection ensures registration order is preserved and each validator is invoked once. It also prevents a validator from being visible only to the startup pipeline or only to synchronous options creation. Registering a validator only as IAsyncValidateOptions<TOptions> is invalid; the default options implementation should report that misconfiguration clearly rather than silently ignoring it.
The same capability model should apply to startup orchestration. API review already removed the proposed ValidateOnStartAsync; ValidateOnStart is the single opt-in and should prefer asynchronous validation when available. IAsyncStartupValidator should derive from IStartupValidator, be registered through IStartupValidator, and be invoked through ValidateAsync instead of Validate by Host.StartAsync. The host should resolve one startup validator and invoke one complete validation path, not run separate sync and async stages.
The default options implementation should preserve this invariant: every options value returned through the normal options services has passed every applicable registered validator through either its synchronous or asynchronous path. IOptionsMonitor<TOptions> can continue returning its last validated value while a new configuration value is validated asynchronously, publishing the replacement only after validation succeeds. This requires no new public API. When no previously validated value exists, a synchronous options API uses Validate and either succeeds or fails explicitly.
This revisits one decision from #128100 using implementation evidence that was not part of the startup-only design. The source-generator discussion in #130263 also treats async-only implementations and IAsyncValidateOptions<TOptions> registrations as supported; this proposal intentionally changes both contracts before they ship in a stable .NET release.
API Proposal
namespace Microsoft.Extensions.Options;
-public interface IAsyncValidateOptions<in TOptions>
+public interface IAsyncValidateOptions<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
Task<ValidateOptionsResult> ValidateAsync(
string? name,
TOptions options,
CancellationToken cancellationToken = default);
}
-public interface IAsyncStartupValidator
+public interface IAsyncStartupValidator : IStartupValidator
{
Task ValidateAsync(CancellationToken cancellationToken = default);
}
The existing AsyncValidateOptions<TOptions, ...> implementations must implement the inherited Validate member. The built-in async-delegate validators return Skip for nonmatching names and a failed result explaining that synchronous validation is unsupported for matching names. They never perform sync-over-async.
The registration behavior also changes:
- All validators are registered as
IValidateOptions<TOptions>.
- An async validation path prefers
ValidateAsync when the resolved validator also implements IAsyncValidateOptions<TOptions>; it does not invoke both methods.
ValidateAsync must perform the validator's complete validation. Validate must provide equivalent synchronous validation or conservatively fail when that is not possible.
- A service registered only as
IAsyncValidateOptions<TOptions> is not a valid options-validator registration and should produce a clear configuration error.
- Startup validation is registered as
IStartupValidator. Host.StartAsync calls ValidateAsync instead of Validate when that instance also implements IAsyncStartupValidator; it never invokes both methods.
ValidateOnStart remains the only options-builder opt-in and includes both sync-only and async-capable validators.
API Usage
An implementation with no safe synchronous equivalent rejects synchronous validation instead of allowing the options value to bypass validation:
public sealed class StorageOptionsValidator(IStorageService storage)
: IAsyncValidateOptions<StorageOptions>
{
public ValidateOptionsResult Validate(string? name, StorageOptions options)
{
if (name is not null && name != Options.DefaultName)
{
return ValidateOptionsResult.Skip;
}
return ValidateOptionsResult.Fail(
"StorageOptions requires asynchronous validation.");
}
public async Task<ValidateOptionsResult> ValidateAsync(
string? name,
StorageOptions options,
CancellationToken cancellationToken = default)
{
if (name is not null && name != Options.DefaultName)
{
return ValidateOptionsResult.Skip;
}
return await storage.ExistsAsync(options.Endpoint, cancellationToken)
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(
$"Storage endpoint '{options.Endpoint}' does not exist.");
}
}
// Register every validator through the common validation contract.
services.AddSingleton<IValidateOptions<StorageOptions>, StorageOptionsValidator>();
Alternative Designs
Keep separate sync and async registration collections
Separate registrations make it cheap to detect whether async validators exist and allow them to be resolved independently in a validation scope. They also create two potentially inconsistent definitions of "all validators," require duplicate-registration handling for implementations that support both paths, and allow direct registrations to be silently absent from one pipeline.
The unified model requires the implementation to resolve IValidateOptions<TOptions> in the appropriate scope and inspect each validator's capabilities. That is additional internal work, but it keeps registration semantics and validator ordering unambiguous.
Keep separate startup validation stages
Running IStartupValidator.Validate before IAsyncStartupValidator.ValidateAsync can avoid asynchronous work when sync validation fails. It also exposes the pipeline split through two independently registered services and requires the host to invoke both.
A complete ValidateAsync implementation can preserve the useful ordering internally by running sync-only validators before starting asynchronous work. Separate host-level stages are therefore unnecessary.
Keep the interfaces independent
The default options implementation could special-case async-only registrations and fail every synchronous access conservatively. That prevents silent skipping in known framework paths, but a direct validator cannot express supported synchronous behavior through its declared contract.
Preserve contravariance
This would require making the established IValidateOptions<TOptions> interface contravariant. Changing that shipped interface is not acceptable, so the new async interface must instead become invariant.
Perform sync-over-async
The synchronous member must never block on a potentially incomplete task. Implementations without a true synchronous path should return a failure or throw an appropriate exception.
Risks
- Preview API compatibility: Existing
IAsyncValidateOptions<TOptions> implementations must add Validate, existing IAsyncStartupValidator implementations must add IStartupValidator.Validate, contravariant conversions stop compiling, and assemblies built against the previous preview shape may need recompilation.
- Preview registration compatibility: Validator registrations currently made as
IAsyncValidateOptions<TOptions> must move to IValidateOptions<TOptions>, and startup-validator registrations must use IStartupValidator. Framework registration helpers and generated guidance must be updated consistently.
- Behavioral change: A synchronous options access may now fail where it previously returned a value after silently skipping an async validator. This is the intended safety behavior.
- Validator semantics: Async paths invoke
ValidateAsync instead of invoking both methods. Options and startup-validator implementations must therefore make ValidateAsync complete rather than treating it as only the asynchronous portion of validation.
- Discovery and lifetimes: The implementation can no longer use the presence of an
IAsyncValidateOptions<TOptions> service registration as a cheap capability check. Validators must be resolved from the common collection in the correct scope before their async capability is known.
- Pre-startup access and startup ordering: Current preview behavior can expose and cache an options value before its async validator has run. Unified registration makes pre-startup synchronous access invoke
Validate; once async startup begins, that pipeline must prefer ValidateAsync rather than fail through an unsupported synchronous path.
The public signature changes themselves add no runtime cost. Unified registration, scoped capability discovery, single-path startup orchestration, and stale-value reload handling are implementation changes and require no additional public API.
Background and motivation
IAsyncValidateOptions<TOptions>was approved in #128100 as an independent async counterpart toIValidateOptions<TOptions>. At that time, async options validation was designed primarily for startup and runtime revalidation throughIOptionsMonitor<TOptions>was explicitly out of scope.Implementing post-startup revalidation exposed a correctness gap. Options still have synchronous entry points, including
IOptions<TOptions>.Value,IOptionsSnapshot<TOptions>.Get,IOptionsMonitor<TOptions>.Get, andIOptionsFactory<TOptions>.Create. If a validator implements only the async interface, these paths have no synchronous contract through which to invoke it or determine whether it applies to the requested options name. A newly created value can therefore be returned without running every validator registered for its options type.This can happen before startup as well as during reload. Resolving options before the async startup validator runs currently enters the synchronous pipeline, which does not see an
IAsyncValidateOptions<TOptions>registration. The value can be returned and cached before the async validator has run at all.The sibling async DataAnnotations APIs make the synchronous contract explicit:
IAsyncValidatableObjectinheritsIValidatableObject, whileAsyncValidationAttributeinheritsValidationAttributeand requires a synchronous implementation or an explicit failure when synchronous validation is unsupported.DataAnnotationValidateOptions<TOptions>likewise already implements both options-validation interfaces.IAsyncValidateOptions<TOptions>should follow the same model. Its inheritedValidateimplementation can perform equivalent synchronous validation, returnSkipwhen the validator does not apply, or conservatively fail when synchronous validation is unsupported. It must not block onValidateAsync.This also enables a single DI registration contract. All validators, including async-capable validators, are registered as
IValidateOptions<TOptions>. Synchronous paths enumerate that collection and callValidate. Async paths enumerate the same collection and callValidateAsyncinstead when a validator also implementsIAsyncValidateOptions<TOptions>.IAsyncValidateOptions<TOptions>becomes a capability interface rather than a second validator collection.Using one collection ensures registration order is preserved and each validator is invoked once. It also prevents a validator from being visible only to the startup pipeline or only to synchronous options creation. Registering a validator only as
IAsyncValidateOptions<TOptions>is invalid; the default options implementation should report that misconfiguration clearly rather than silently ignoring it.The same capability model should apply to startup orchestration. API review already removed the proposed
ValidateOnStartAsync;ValidateOnStartis the single opt-in and should prefer asynchronous validation when available.IAsyncStartupValidatorshould derive fromIStartupValidator, be registered throughIStartupValidator, and be invoked throughValidateAsyncinstead ofValidatebyHost.StartAsync. The host should resolve one startup validator and invoke one complete validation path, not run separate sync and async stages.The default options implementation should preserve this invariant: every options value returned through the normal options services has passed every applicable registered validator through either its synchronous or asynchronous path.
IOptionsMonitor<TOptions>can continue returning its last validated value while a new configuration value is validated asynchronously, publishing the replacement only after validation succeeds. This requires no new public API. When no previously validated value exists, a synchronous options API usesValidateand either succeeds or fails explicitly.This revisits one decision from #128100 using implementation evidence that was not part of the startup-only design. The source-generator discussion in #130263 also treats async-only implementations and
IAsyncValidateOptions<TOptions>registrations as supported; this proposal intentionally changes both contracts before they ship in a stable .NET release.API Proposal
The existing
AsyncValidateOptions<TOptions, ...>implementations must implement the inheritedValidatemember. The built-in async-delegate validators returnSkipfor nonmatching names and a failed result explaining that synchronous validation is unsupported for matching names. They never perform sync-over-async.The registration behavior also changes:
IValidateOptions<TOptions>.ValidateAsyncwhen the resolved validator also implementsIAsyncValidateOptions<TOptions>; it does not invoke both methods.ValidateAsyncmust perform the validator's complete validation.Validatemust provide equivalent synchronous validation or conservatively fail when that is not possible.IAsyncValidateOptions<TOptions>is not a valid options-validator registration and should produce a clear configuration error.IStartupValidator.Host.StartAsynccallsValidateAsyncinstead ofValidatewhen that instance also implementsIAsyncStartupValidator; it never invokes both methods.ValidateOnStartremains the only options-builder opt-in and includes both sync-only and async-capable validators.API Usage
An implementation with no safe synchronous equivalent rejects synchronous validation instead of allowing the options value to bypass validation:
Alternative Designs
Keep separate sync and async registration collections
Separate registrations make it cheap to detect whether async validators exist and allow them to be resolved independently in a validation scope. They also create two potentially inconsistent definitions of "all validators," require duplicate-registration handling for implementations that support both paths, and allow direct registrations to be silently absent from one pipeline.
The unified model requires the implementation to resolve
IValidateOptions<TOptions>in the appropriate scope and inspect each validator's capabilities. That is additional internal work, but it keeps registration semantics and validator ordering unambiguous.Keep separate startup validation stages
Running
IStartupValidator.ValidatebeforeIAsyncStartupValidator.ValidateAsynccan avoid asynchronous work when sync validation fails. It also exposes the pipeline split through two independently registered services and requires the host to invoke both.A complete
ValidateAsyncimplementation can preserve the useful ordering internally by running sync-only validators before starting asynchronous work. Separate host-level stages are therefore unnecessary.Keep the interfaces independent
The default options implementation could special-case async-only registrations and fail every synchronous access conservatively. That prevents silent skipping in known framework paths, but a direct validator cannot express supported synchronous behavior through its declared contract.
Preserve contravariance
This would require making the established
IValidateOptions<TOptions>interface contravariant. Changing that shipped interface is not acceptable, so the new async interface must instead become invariant.Perform sync-over-async
The synchronous member must never block on a potentially incomplete task. Implementations without a true synchronous path should return a failure or throw an appropriate exception.
Risks
IAsyncValidateOptions<TOptions>implementations must addValidate, existingIAsyncStartupValidatorimplementations must addIStartupValidator.Validate, contravariant conversions stop compiling, and assemblies built against the previous preview shape may need recompilation.IAsyncValidateOptions<TOptions>must move toIValidateOptions<TOptions>, and startup-validator registrations must useIStartupValidator. Framework registration helpers and generated guidance must be updated consistently.ValidateAsyncinstead of invoking both methods. Options and startup-validator implementations must therefore makeValidateAsynccomplete rather than treating it as only the asynchronous portion of validation.IAsyncValidateOptions<TOptions>service registration as a cheap capability check. Validators must be resolved from the common collection in the correct scope before their async capability is known.Validate; once async startup begins, that pipeline must preferValidateAsyncrather than fail through an unsupported synchronous path.The public signature changes themselves add no runtime cost. Unified registration, scoped capability discovery, single-path startup orchestration, and stale-value reload handling are implementation changes and require no additional public API.