Skip to content

Add async startup validation for Microsoft.Extensions.Options#128788

Open
ViveliDuCh wants to merge 2 commits into
dotnet:mainfrom
ViveliDuCh:async-validation-options
Open

Add async startup validation for Microsoft.Extensions.Options#128788
ViveliDuCh wants to merge 2 commits into
dotnet:mainfrom
ViveliDuCh:async-validation-options

Conversation

@ViveliDuCh
Copy link
Copy Markdown
Member

Fixes #128100

Implements async startup validation for Microsoft.Extensions.Options as approved in API review.

Follow-up to #128656

OptionsFactory.Create() and IOptions<T>.Value remain fully synchronous. Async validators run in a separate step during Host.StartAsync() only. Lazy validation via .Value and runtime reload via IOptionsMonitor<T> are not affected. See design rationale.

What's included

  • IAsyncValidateOptions<TOptions> — async counterpart to IValidateOptions<T> returning Task<ValidateOptionsResult>
  • IAsyncStartupValidator — async counterpart to IStartupValidator for host-level startup validation
  • AsyncValidateOptions<TOptions> through AsyncValidateOptions<TOptions, TDep1..TDep5> — lambda-based async validators (0–5 dependencies), mirroring the sync ValidateOptions<T, TDep> family
  • Async Validate overloads on OptionsBuilder<TOptions> (0–5 dependencies) — registers IAsyncValidateOptions<T> via lambda
  • DataAnnotationValidateOptionsAsync<TOptions> — async counterpart to DataAnnotationValidateOptions<T>, calls Validator.TryValidateObjectAsync and walks [ValidateObjectMembers]/[ValidateEnumeratedItems] recursively
  • ValidateDataAnnotationsAsync() extension method on OptionsBuilderDataAnnotationsExtensions
  • StartupValidator extended to implement IAsyncStartupValidator — runs sync validators first, then async validators, collecting all OptionsValidationExceptions
  • Host.StartAsync() updated to prefer IAsyncStartupValidator when available, falling back to sync IStartupValidator
  • ValidateOnStart() extended to register async validator entries alongside sync entries when IAsyncValidateOptions<T> services are present
  • Ref assembly updates for the full async Options API surface

Not in scope

  • No IAsyncOptions<T>, IAsyncOptionsSnapshot<T>, or IAsyncOptionsMonitor<T> — lazy async resolution is blocked by IOptions<T>.Value being a C# property and OptionsCache using Lazy<T> / ConcurrentDictionary (no async counterparts in the BCL)
  • No runtime async re-validation on IOptionsMonitor<T> config changes — OnChange callbacks remain sync-only
  • No Options validation source generator changes — the [OptionsValidator] source generator emitting ValidateAsync() for IAsyncValidateOptions<T> is tracked separately

Implementation notes

  • StartupValidator.ValidateAsync() calls sync Validate() first (which triggers IOptions<T>.ValueOptionsFactory.Create() → sync validators), then iterates registered async validators sequentially
  • ValidateOnStart() registers both sync and async entries in StartupValidatorOptions. Async entries are only added when IAsyncValidateOptions<T> services are detected at configure-time
  • Host.StartAsync() resolves IAsyncStartupValidator first; if not available, falls back to IStartupValidator (backward compatible)
  • Multiple OptionsValidationExceptions from different async validators are aggregated into an AggregateException when more than one fails

API review decisions reflected

  • Task<ValidateOptionsResult> return type on IAsyncValidateOptions<T> (not ValueTask)
  • IAsyncStartupValidator as a separate interface (not extending IStartupValidator)
  • Async lambda Validate overloads on OptionsBuilder<T> accept Func<TOptions, ..., CancellationToken, Task<bool>>
  • StartupValidator implements both IStartupValidator and IAsyncStartupValidator, registered as a single service

ViveliDuCh and others added 2 commits May 29, 2026 15:22
Implement the async validation API surface approved in
dotnet#128100 (DataAnnotations layer):

New types:
- AsyncValidationAttribute: abstract base class for async validation
  scenarios. IsValid(object?, ValidationContext) is abstract override;
  IsValidAsync is the primary async entry point. Sealed override of
  IsValid(object?) delegates to the context overload.
- IAsyncValidatableObject: extends IValidatableObject with
  ValidateAsync returning IAsyncEnumerable<ValidationResult>.
  No default interface method.

New Validator async methods (8 total):
- TryValidateObjectAsync (2 overloads)
- TryValidatePropertyAsync
- TryValidateValueAsync
- ValidateObjectAsync (2 overloads)
- ValidatePropertyAsync
- ValidateValueAsync

All return Task/Task<bool> per API review decision.

Implementation details:
- 3-step async pipeline matching sync structure: property validation,
  type attributes, IAsyncValidatableObject/IValidatableObject.
- Property validation runs in parallel via Task.WhenAny with linked
  CancellationTokenSource for cooperative breakOnFirstError.
- Per-value validation is two-phase: sync attributes first (abort
  early), then async attributes in parallel.
- try/finally blocks observe in-flight tasks on all exit paths to
  prevent UnobservedTaskException from the finalizer thread.

Refactoring:
- Extracted EnsureValidationResultErrorMessage as private protected
  helper in ValidationAttribute, shared by sync GetValidationResult
  and async GetValidationResultAsync.
- Added RequiresValidationContext XML remark clarifying async behavior.
- Added ValidationContext.Items thread-safety remark for parallel
  async validation.

Tests: ~112 test cases covering all async Validator methods, mixed
sync/async attributes, breakOnFirstError with parallel async,
cancellation propagation, IAsyncValidatableObject, class-level async
attributes, error message formatting, sync fallback, and gate-based
concurrency probing for deterministic parallelism verification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add IAsyncValidateOptions<T> and IAsyncStartupValidator interfaces
- Add AsyncValidateOptions<T, TDep1..TDep5> lambda-based validators
- Add async Validate overloads on OptionsBuilder<T> (0-5 dependencies)
- Extend ValidateOnStart to prefer IAsyncStartupValidator when available
- Add DataAnnotationValidateOptionsAsync<T> and ValidateDataAnnotationsAsync extension
- Wire IAsyncStartupValidator into Host.StartAsync
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

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

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds asynchronous startup validation to Microsoft.Extensions.Options, building on async DataAnnotations support from PR #128656. It introduces a parallel async validation pipeline that runs during Host.StartAsync() while leaving the synchronous IOptions<T>.Value / OptionsFactory.Create() path untouched, so existing behavior is preserved.

Changes:

  • New public APIs: IAsyncValidateOptions<T>, IAsyncStartupValidator, AsyncValidateOptions<TOptions> (0–5 deps), DataAnnotationValidateOptionsAsync<T>, async Validate overloads on OptionsBuilder<T>, and ValidateDataAnnotationsAsync().
  • StartupValidator now also implements IAsyncStartupValidator (running sync validators first, then async); ValidateOnStart() registers async entries when IAsyncValidateOptions<T> services are present.
  • Host.StartAsync() prefers IAsyncStartupValidator over IStartupValidator when both are registered, with corresponding ref-assembly updates and System.ComponentModel.Annotations async validation primitives (AsyncValidationAttribute, IAsyncValidatableObject, async Validator methods).

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/AsyncValidationAttribute.cs New abstract base for async validation attributes.
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/IAsyncValidatableObject.cs New interface extending IValidatableObject with IAsyncEnumerable results.
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/Validator.cs Adds 8 async Validator methods with two-phase parallel async validation.
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationAttribute.cs Adds EnsureValidationResultErrorMessage helper used by both sync and async paths.
src/libraries/System.ComponentModel.Annotations/src/System/ComponentModel/DataAnnotations/ValidationContext.cs Doc-only thread-safety remarks for Items.
src/libraries/System.ComponentModel.Annotations/src/System.ComponentModel.Annotations.csproj Includes new files in compilation.
src/libraries/System.ComponentModel.Annotations/ref/System.ComponentModel.Annotations.cs Ref assembly for new async APIs.
src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidatorTests.cs Extensive tests for Validator.*Async methods, parallelism, cancellation, and IAsyncValidatableObject.
src/libraries/System.ComponentModel.Annotations/tests/System/ComponentModel/DataAnnotations/ValidationAttributeTests.cs Tests for AsyncValidationAttribute.
src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs New interface for async options validation.
src/libraries/Microsoft.Extensions.Options/src/IAsyncStartupValidator.cs New interface for async startup validation.
src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs Async validator classes (0–5 dependencies).
src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs Async Validate(...) overloads.
src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs ValidateOnStart now registers IAsyncStartupValidator and async validator entries.
src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs Adds _asyncValidators dictionary.
src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs StartupValidator implements IAsyncStartupValidator.
src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs Ref assembly updates for async API surface.
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptionsAsync.cs Async DataAnnotations validator for options (recursive [ValidateObjectMembers]/[ValidateEnumeratedItems]).
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs Marks the class partial.
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.Async.cs Adds ValidateDataAnnotationsAsync<T> extension.
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/Microsoft.Extensions.Options.DataAnnotations.csproj Excludes async sources for non-NetCoreAppCurrent.
src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.csproj / .Async.cs Ref assembly entries for async API.
src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs Prefers IAsyncStartupValidator over IStartupValidator during StartAsync.

{
vo._asyncValidators[(typeof(TOptions), optionsBuilder.Name)] = async (CancellationToken ct) =>
{
// Retrieve the options value (already created by sync Validate() call)
Comment on lines +100 to +109
IAsyncStartupValidator? asyncValidator = Services.GetService<IAsyncStartupValidator>();
if (asyncValidator is not null)
{
await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false);
}
else
{
IStartupValidator? validator = Services.GetService<IStartupValidator>();
validator?.Validate();
}
Comment on lines +56 to +75
public async Task ValidateAsync(CancellationToken cancellationToken = default)
{
// Run sync validators first (this triggers options creation + sync validation)
Validate();

// Then run async validators
List<Exception>? exceptions = null;

foreach (Func<CancellationToken, Task> asyncValidator in _validatorOptions._asyncValidators.Values)
{
try
{
await asyncValidator(cancellationToken).ConfigureAwait(false);
}
catch (OptionsValidationException ex)
{
exceptions ??= new();
exceptions.Add(ex);
}
}
Comment on lines +42 to +71
.Configure<IOptionsMonitor<TOptions>, IEnumerable<IAsyncValidateOptions<TOptions>>>((vo, options, asyncValidators) =>
{
// Materialize the validators into a list to check if any are registered
var validators = new List<IAsyncValidateOptions<TOptions>>(asyncValidators);
if (validators.Count > 0)
{
vo._asyncValidators[(typeof(TOptions), optionsBuilder.Name)] = async (CancellationToken ct) =>
{
// Retrieve the options value (already created by sync Validate() call)
TOptions optionsValue = options.Get(optionsBuilder.Name);

// Run async validators
List<string>? failures = null;
foreach (IAsyncValidateOptions<TOptions> validator in validators)
{
ValidateOptionsResult result = await validator.ValidateAsync(optionsBuilder.Name, optionsValue, ct).ConfigureAwait(false);
if (result is not null && result.Failed)
{
failures ??= new List<string>();
failures.AddRange(result.Failures);
}
}

if (failures is not null && failures.Count > 0)
{
throw new OptionsValidationException(optionsBuilder.Name, typeof(TOptions), failures);
}
};
}
});
IAsyncStartupValidator? asyncValidator = Services.GetService<IAsyncStartupValidator>();
if (asyncValidator is not null)
{
await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case of cancellation, do we want to just throw or do we still want that to go through logging?

@@ -7,6 +7,10 @@
<Compile Include="Microsoft.Extensions.Options.DataAnnotations.cs" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == '$(NetCoreAppCurrent)'">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net11.0'))"?

I think we will always want this for net11+ builds, and don't want it to be affected by future bumps to NetCoreAppCurrent?

{
errors ??= new List<string>();

foreach (ValidationResult result in results!)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the null suppression here needed?

continue;
}

object? value = propertyInfo!.GetValue(options);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here for the null suppression.

visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance);
visited.Add(options);

results ??= new List<ValidationResult>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like results is never null here so this line should be redundant?

{
visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance);
visited.Add(options);
results ??= new List<ValidationResult>();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

@@ -16,6 +16,11 @@
<Compile Include="$(CoreLibSharedDir)System\Runtime\CompilerServices\CompilerLoweringPreserveAttribute.cs" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' != '$(NetCoreAppCurrent)'">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here about the condition.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API Proposal]: Async Options Validation for Microsoft.Extensions.Options

4 participants