Skip to content

[API Proposal]: Unify sync and async options validation contracts #130719

Closed

Description

@halter73

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 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. Edit: But at what cost? In this comment below, I argue it's not worth a new constructor to OptionsFactory to catch this misregistration at runtime. We don't guard against misregistering IConfigureNamedOptions.
  • 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.

Activity

  1. added
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on Jul 14, 2026
  2. dotnet-policy-service commented on Jul 14, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-extensions-options
    See info in area-owners.md if you want to be subscribed.

  3. added
    blockingMarks issues that we want to fast track in order to unblock other important work
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    and removed
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    untriagedNew issue has not been triaged by the area owner
    on Jul 14, 2026
  4. changed the title [-][API Proposal]: Require a synchronous contract for IAsyncValidateOptions<TOptions>[/-] [+][API Proposal]: Unify sync and async options validation contracts[/+] on Jul 14, 2026
  5. tarekgh commented on Jul 14, 2026

    @tarekgh
    Member

    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.

  6. tarekgh commented on Jul 14, 2026

    @tarekgh
    Member

    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, Validate could also use a synchronously-completed ValidateAsync result before failing, like ChangeToken.OnChange does. 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.

  7. tarekgh commented on Jul 14, 2026

    @tarekgh
    Member

    Since ValidateAsync is called instead of Validate (never both), every async validator's ValidateAsync has to be a complete superset of its Validate, 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 emit Validate / register as IValidateOptions<T>. These should be gated to ship together so generated code compiles against the new shape.

  8. tarekgh commented on Jul 14, 2026

    @tarekgh
    Member

    One implementation risk worth calling out: ValidateOnStart currently forces validation via options.Get(name), which runs the sync validators inline. Under unification that would invoke an async-only validator's conservative Validate and fail before the async stage runs. The single ValidateAsync path will need to create the value and dispatch per validator by capability itself, rather than relying on options.Get. Doable internally, but OptionsFactory.Create's "always validate inline and throw" contract makes this the trickiest part and deserves a design note.

  9. tarekgh commented on Jul 14, 2026

    @tarekgh
    Member

    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 on IOptions<T>.Value when 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.

  10. halter73 commented on Jul 15, 2026

    @halter73
    MemberAuthor

    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.

    💯 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.

    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).

    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. 11 remaining items

  12. added
    api-approvedAPI was approved in API review, it can be implemented
    breaking-changeIssue or PR that represents a breaking API or functional change over a previous release.
    and removed
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on Jul 21, 2026
  13. added
    needs-breaking-change-doc-createdBreaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet
    on Jul 21, 2026
  14. dotnet-policy-service commented on Jul 21, 2026

    @dotnet-policy-service
    Contributor

    Added needs-breaking-change-doc-created label because this issue has the breaking-change label.

    1. Create and link to this issue a matching issue in the dotnet/docs repo using the breaking change documentation template, then remove this needs-breaking-change-doc-created label.

    Tagging @dotnet/compat for awareness of the breaking change.

  15. halter73 commented on Jul 23, 2026

    @halter73
    MemberAuthor

    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 IAsyncStartupValidator derive from IStartupValidator, mirroring IAsyncValidateOptions<TOptions> : IValidateOptions<TOptions>. I'd instead like to obsolete IStartupValidator and 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 both Validate and ValidateAsync: 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 from IValidateOptions<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. Forcing IAsyncStartupValidator to also carry a Validate() it never uses is unnecessary, and having both Validate and ValidateAsync on 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). Unlike IAsyncValidateOptions<TOptions>, we don't need both.

    Back-compat under the revised shape:

    • ValidateOnStart registers the built-in validator as an IAsyncStartupValidator, and still implements IStartupValidator internally so existing GetService<IStartupValidator>() callers keep working.
    • Host.StartAsync prefers IAsyncStartupValidator.ValidateAsync. A user-registered custom IStartupValidator still 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.
    • Obsoleting IStartupValidator warns the few implementers/direct resolvers and paves the way to eventually remove it. Dropping the shared interface also lets us support multiple IAsyncStartupValidator registrations cleanly.

    This is a preview-only change. IAsyncStartupValidator hasn't shipped in a stable release. I'll take it to API review over email as well; noting it here for the record.

  16. rosebyte commented on Jul 23, 2026

    @rosebyte
    Member

    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?

  17. halter73 commented on Jul 23, 2026

    @halter73
    MemberAuthor

    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, not IValidateOptions which will continue to work as it does today.

    I don't think that many people implement or call into IStartupValidator today. 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 IStartupValidator implementors, but I think migrating to async shouldn't be too bad on the implementation side, because you can return Task.CompletedTask in the new IAsyncStartupValidator, 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 default IStartupValidator and if there were multiple service registrations, only run the last one. The new design runs all IAsyncStartupValidator's which I think better matches developer expectations.

    Of course, grep.app isn't going to find all usages of the IStartupValidator type, but I doubt there are many people who really need to call this from a synchronous context. If no async startup validators are registered, ValidateAsync should always return an already-completed Task anyway.

    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 associated IAsyncValidateOptions, so the options machinery will call the synchronous IAsyncValidateOptions.Validate when necessary. IAsyncValidateOptions.Validate can choose to do sync-over-async or throw or whatever else the implementor decides makes the most sense.

    By obsoleting IStartupValidator we warn people you are still trying to use the newly-obsoleted type that they should be calling ValidateAsync on the new interface to run all validators registered for startup, sync or async, in the most optimal way. Unlike, IValidateOptions in general, which often needs to be called from synchronous context, we think that most callers into IStartupValidator like Host.StartAsync can and should be calling ValidateAsync now instead.

    @rosebyte Do you have a particular need for IStartupValidator in particular to stay synchronous? Or are you okay so long as we keep supporting the synchronous IValidateOptions?

  18. halter73 commented on Aug 3, 2026

    @halter73
    MemberAuthor

    IAsyncValidateOptions<T> registered instead of IValidateOptions<T>

    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 for IConfigureNamedOptions<T> : IConfigureOptions<T>: it enumerates IConfigureOptions<T> and discovers the named capability by casting. The host does the same for IHostedLifecycleService : 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 as IValidateOptions<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 through OptionsFactory<T> solely to reject it at runtime. I don't think either should block this change.

  19. ViveliDuCh commented on Sep 1, 2026

    @ViveliDuCh
    Member

    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.

  20. removed
    needs-breaking-change-doc-createdBreaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet
    on Sep 24, 2026
  21. locked and limited conversation to collaborators on Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

api-approvedAPI was approved in API review, it can be implementedarea-Extensions-Optionsbreaking-changeIssue or PR that represents a breaking API or functional change over a previous release.

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions