Repository navigation
Description
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.
(Edit: Registration helpers and documentation should direct users to register it as IValidateOptions<TOptions>; no additional runtime detection is proposed. See my later comment for more details as to why.)
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
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.A service registered only asEdit: But at what cost? In this comment below, I argue it's not worth a new constructor toIAsyncValidateOptions<TOptions>is not a valid options-validator registration and should produce a clear configuration error.OptionsFactoryto catch this misregistration at runtime. We don't guard against misregisteringIConfigureNamedOptions.- Startup validation is registered as
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:
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 addValidate, existingIAsyncStartupValidatorimplementations must addIStartupValidator.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 toIValidateOptions<TOptions>, and startup-validator registrations must useIStartupValidator. 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
ValidateAsyncinstead of invoking both methods. Options and startup-validator implementations must therefore makeValidateAsynccomplete 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 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.
Activity
dotnet-policy-service commentedon Jul 14, 2026
Tagging subscribers to this area: @dotnet/area-extensions-options
See info in area-owners.md if you want to be subscribed.
[-][API Proposal]: Require a synchronous contract for IAsyncValidateOptions<TOptions>[/-][+][API Proposal]: Unify sync and async options validation contracts[/+]tarekgh commentedon Jul 14, 2026
The proposal says registration order is preserved, but also suggests running sync-only validators before async ones during startup. Those conflict for a mixed order like
[asyncValidator, syncValidator]. Can we lock down one rule? I'd suggest iterating strictly in registration order and awaiting async validators inline, treating "sync first" only as an optimization when it can't change which failure is reported.tarekgh commentedon Jul 14, 2026
On the "sync access fails" behavior: the fail-rather-than-silently-skip model is right, but we can make the failure rare instead of the norm. When an async-capable validator is registered, we could auto-ensure async pre-validation runs and caches a validated value, so normal host usage serves the cached instance synchronously and only fails when a value has genuinely never been validated (pre-startup or no host). The monitor's last-known-good behavior can extend to first resolution too. For the built-in delegate validators,
Validatecould also use a synchronously-completedValidateAsyncresult before failing, likeChangeToken.OnChangedoes. And it's worth naming an async retrieval API as the real long-term fix, even if it stays out of scope here. Either way the failure message should be actionable.tarekgh commentedon Jul 14, 2026
Since
ValidateAsyncis called instead ofValidate(never both), every async validator'sValidateAsynchas to be a complete superset of itsValidate, otherwise sync checks get dropped. DataAnnotations already satisfies this, but the source generator in #130263 and any generated/user code need to follow the same rule and emitValidate/ register asIValidateOptions<T>. These should be gated to ship together so generated code compiles against the new shape.tarekgh commentedon Jul 14, 2026
One implementation risk worth calling out:
ValidateOnStartcurrently forces validation viaoptions.Get(name), which runs the sync validators inline. Under unification that would invoke an async-only validator's conservativeValidateand fail before the async stage runs. The singleValidateAsyncpath will need to create the value and dispatch per validator by capability itself, rather than relying onoptions.Get. Doable internally, butOptionsFactory.Create's "always validate inline and throw" contract makes this the trickiest part and deserves a design note.tarekgh commentedon Jul 14, 2026
Heads up that this flips the behavior of the delegate-based
ValidateAsync(...)in preview: today it is silently skipped on sync access, after this it fails the matching name onIOptions<T>.Valuewhen there is no pre-validated value. That is the intended safety fix, but it is the most visible break, so it should be explicit in the migration notes. If we adopt the auto pre-validation idea above, this becomes rare in practice since the cached validated value would be served.halter73 commentedon Jul 15, 2026
💯 I think for options validation in particular, we should focus on correctness over speed, and strictly following registration order like we did for sync validators for async validators as well, even if they're interleaved makes the most sense to me.
Exactly my thoughts as well. I think using a stale-but-valid options object is better than throwing whenever possible. I have a local branch experimenting with this idea, and I think it should be doable. However, I think we should make this change whether or not we decide to return stale-but-valid options or throw in these cases.
11 remaining items
dotnet-policy-service commentedon Jul 21, 2026
Added
needs-breaking-change-doc-createdlabel because this issue has thebreaking-changelabel.needs-breaking-change-doc-createdlabel.Tagging @dotnet/compat for awareness of the breaking change.
halter73 commentedon Jul 23, 2026
While implementing this (draft in #131197), @oroztocil and I would like to adjust one part of the approved API before it ships.
The approved proposal has
IAsyncStartupValidatorderive fromIStartupValidator, mirroringIAsyncValidateOptions<TOptions> : IValidateOptions<TOptions>. I'd instead like to obsoleteIStartupValidatorand drop the inheritance:+[Obsolete("Implement IAsyncStartupValidator instead.")] public interface IStartupValidator { void Validate(); } public interface IAsyncStartupValidator { Task ValidateAsync(CancellationToken cancellationToken = default); }The two halves of the proposal aren't symmetric.
IAsyncValidateOptions<TOptions>genuinely needs bothValidateandValidateAsync: options have synchronous entry points (IOptions<TOptions>.Value,IOptionsSnapshot<TOptions>.Get,IOptionsMonitor<TOptions>.Get,IOptionsFactory<TOptions>.Create) that must invoke the validator or fail explicitly, so deriving fromIValidateOptions<TOptions>is essential there.Startup validation has no synchronous entry point. It's only ever invoked from
Host.StartAsync, which is async. We never call it from synchronous code. ForcingIAsyncStartupValidatorto also carry aValidate()it never uses is unnecessary, and having bothValidateandValidateAsyncon the same startup contract is confusing (it's unclear whether one or both run, and implementers are tempted to fill in a sync method that never executes). UnlikeIAsyncValidateOptions<TOptions>, we don't need both.Back-compat under the revised shape:
ValidateOnStartregisters the built-in validator as anIAsyncStartupValidator, and still implementsIStartupValidatorinternally so existingGetService<IStartupValidator>()callers keep working.Host.StartAsyncprefersIAsyncStartupValidator.ValidateAsync. A user-registered customIStartupValidatorstill wins for behavioral back-compat; replacing the default already opts out of default behavior. We may log a startup warning when only a legacy sync validator is present.IStartupValidatorwarns the few implementers/direct resolvers and paves the way to eventually remove it. Dropping the shared interface also lets us support multipleIAsyncStartupValidatorregistrations cleanly.This is a preview-only change.
IAsyncStartupValidatorhasn't shipped in a stable release. I'll take it to API review over email as well; noting it here for the record.rosebyte commentedon Jul 23, 2026
I got the reasoning, nonetheless, most validators will likely be synchronous. We don't use ValueTask because it doesn't provide as much benefit as it used to before the runtime async improvements, right?
halter73 commentedon Jul 23, 2026
I agree that most validators are and will continue to be synchronous. The only change I'm proposing to the approved API is to
IStartupValidator, notIValidateOptionswhich will continue to work as it does today.I don't think that many people implement or call into
IStartupValidatortoday. There's certainly some external usage, but I see mostly tests. The non-test callers seem to all be running in an async context, which make sense because this is designed to only be run once during process/host startup. IHost itself only has an async start method.I see some custom
IStartupValidatorimplementors, but I think migrating to async shouldn't be too bad on the implementation side, because you can returnTask.CompletedTaskin the newIAsyncStartupValidator, and otherwise do the same thing you did before. The new async interface has the upside of supporting multiple registration while the previous version didn't. This line for instance, would replace the defaultIStartupValidatorand if there were multiple service registrations, only run the last one. The new design runs allIAsyncStartupValidator's which I think better matches developer expectations.Of course, grep.app isn't going to find all usages of the
IStartupValidatortype, but I doubt there are many people who really need to call this from a synchronous context. If no async startup validators are registered,ValidateAsyncshould always return an already-completedTaskanyway.And If you really want to force the sync validation path on
IAsyncValidateOptions, you can simply not register it for startup validation. The whole point of this API change is to ensure it's not possible to resolve options without running all the associatedIAsyncValidateOptions, so the options machinery will call the synchronousIAsyncValidateOptions.Validatewhen necessary.IAsyncValidateOptions.Validatecan choose to do sync-over-async or throw or whatever else the implementor decides makes the most sense.By obsoleting
IStartupValidatorwe warn people you are still trying to use the newly-obsoleted type that they should be callingValidateAsyncon the new interface to run all validators registered for startup, sync or async, in the most optimal way. Unlike,IValidateOptionsin general, which often needs to be called from synchronous context, we think that most callers intoIStartupValidatorlikeHost.StartAsynccan and should be callingValidateAsyncnow instead.@rosebyte Do you have a particular need for
IStartupValidatorin particular to stay synchronous? Or are you okay so long as we keep supporting the synchronousIValidateOptions?halter73 commentedon Aug 3, 2026
I'm not opposed to detecting this, but I don't think we should add a public
OptionsFactory<T>constructor solely to diagnose a service registered under the wrong contract. The footgun I was trying to prevent was a correctly registered validator running or not running depending on when or how options were resolved.This is a different, static registration error.
OptionsFactory<T>already follows this model forIConfigureNamedOptions<T> : IConfigureOptions<T>: it enumeratesIConfigureOptions<T>and discovers the named capability by casting. The host does the same forIHostedLifecycleService : IHostedService. Registering either only as the derived interface makes it invisible to the consumer.We should still make the correct registration easy and obvious.
OptionsBuilder<TOptions>.Validate<TValidateOptions>()already exists and registers the validator asIValidateOptions<TOptions>. We should ensure that helper preserves the async capability and document it as the preferred registration path.If we want additional protection, I would prefer a focused analyzer warning on registrations whose service type is
IAsyncValidateOptions<>over carrying a second validator collection throughOptionsFactory<T>solely to reject it at runtime. I don't think either should block this change.Unify sync/async Options validation contract (#131197)
[OptionsValidator] source generator: emit ValidateAsync() for IAsyncV…
ViveliDuCh commentedon Sep 1, 2026
Breaking-change documentation filed as dotnet/docs#55794 for the .NET 11 Preview 6/7 to RC 1 Options validation contract change implemented by #131197.
Note
This comment was drafted with assistance from GitHub Copilot.