Skip to content

Commit bbd5fc2

Browse files
ViveliDuChCopilot
andauthored
Add async startup validation for Microsoft.Extensions.Options (#128788)
Fixes #128100 Implements async startup validation for `Microsoft.Extensions.Options` as approved in [API review](#128100 (comment)). Follow-up to #128656 Source generator support (`[OptionsValidator]` emitting `ValidateAsync()`) is tracked separately in #128882. `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](#128100). ### 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 `OptionsValidationException`s - `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>.Value` → `OptionsFactory.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 `OptionsValidationException`s 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 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e77a105 commit bbd5fc2

10 files changed

Lines changed: 1186 additions & 2 deletions

File tree

src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public Host(IServiceProvider services,
6666
/// Order:
6767
/// IHostLifetime.WaitForStartAsync
6868
/// Services.GetService{IStartupValidator}().Validate()
69+
/// Services.GetService{IAsyncStartupValidator}().ValidateAsync()
6970
/// IHostedLifecycleService.StartingAsync
7071
/// IHostedService.Start
7172
/// IHostedLifecycleService.StartedAsync
@@ -96,12 +97,31 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
9697
_hostedServices ??= Services.GetRequiredService<IEnumerable<IHostedService>>();
9798
_hostedLifecycleServices = GetHostLifecycles(_hostedServices);
9899

99-
// Call startup validators.
100+
// Two-stage startup validation:
101+
// Stage 1 (sync): Run IStartupValidator.Validate() — iterates _validators dictionary
102+
// (or user's custom implementation if registered).
103+
// If sync validation fails, skip async to avoid expensive I/O on invalid config.
104+
// Stage 2 (async): Run IAsyncStartupValidator.ValidateAsync() — iterates _asyncValidators
105+
// dictionary (or user's custom implementation if registered).
106+
//
107+
// Each interface is resolved independently via DI. TryAddTransient semantics ensure
108+
// user-registered implementations replace the built-in for each interface separately.
100109
IStartupValidator? validator = Services.GetService<IStartupValidator>();
101110
validator?.Validate();
111+
112+
IAsyncStartupValidator? asyncValidator = Services.GetService<IAsyncStartupValidator>();
113+
if (asyncValidator is not null)
114+
{
115+
await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false);
116+
}
102117
}
103118
catch (Exception ex)
104119
{
120+
if (ex is OperationCanceledException)
121+
{
122+
cancellationToken.ThrowIfCancellationRequested();
123+
}
124+
105125
// service factory or validation failed, abort startup.
106126
exceptions.Add(ex);
107127
LogAndRethrow();

src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,18 @@ public partial interface IStartupValidator
145145
{
146146
void Validate();
147147
}
148+
public partial interface IAsyncStartupValidator
149+
{
150+
System.Threading.Tasks.Task ValidateAsync(System.Threading.CancellationToken cancellationToken = default);
151+
}
148152
public partial interface IValidateOptions<TOptions> where TOptions : class
149153
{
150154
Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options);
151155
}
156+
public partial interface IAsyncValidateOptions<in TOptions> where TOptions : class
157+
{
158+
System.Threading.Tasks.Task<Microsoft.Extensions.Options.ValidateOptionsResult> ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default);
159+
}
152160
public static partial class Options
153161
{
154162
public static readonly string DefaultName;
@@ -184,6 +192,18 @@ public OptionsBuilder(Microsoft.Extensions.DependencyInjection.IServiceCollectio
184192
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2, TDep3, TDep4>(System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, bool> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull { throw null; }
185193
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2, TDep3, TDep4, TDep5>(System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, TDep5, bool> validation) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull where TDep5 : notnull { throw null; }
186194
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2, TDep3, TDep4, TDep5>(System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, TDep5, bool> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull where TDep5 : notnull { throw null; }
195+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate(System.Func<TOptions, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation) { throw null; }
196+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate(System.Func<TOptions, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) { throw null; }
197+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep>(System.Func<TOptions, TDep, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation) where TDep : notnull { throw null; }
198+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep>(System.Func<TOptions, TDep, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) where TDep : notnull { throw null; }
199+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2>(System.Func<TOptions, TDep1, TDep2, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation) where TDep1 : notnull where TDep2 : notnull { throw null; }
200+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2>(System.Func<TOptions, TDep1, TDep2, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull { throw null; }
201+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2, TDep3>(System.Func<TOptions, TDep1, TDep2, TDep3, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull { throw null; }
202+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2, TDep3>(System.Func<TOptions, TDep1, TDep2, TDep3, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull { throw null; }
203+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2, TDep3, TDep4>(System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull { throw null; }
204+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2, TDep3, TDep4>(System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull { throw null; }
205+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2, TDep3, TDep4, TDep5>(System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, TDep5, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull where TDep5 : notnull { throw null; }
206+
public virtual Microsoft.Extensions.Options.OptionsBuilder<TOptions> Validate<TDep1, TDep2, TDep3, TDep4, TDep5>(System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, TDep5, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) where TDep1 : notnull where TDep2 : notnull where TDep3 : notnull where TDep4 : notnull where TDep5 : notnull { throw null; }
187207
}
188208
public partial class OptionsCache<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions> : Microsoft.Extensions.Options.IOptionsMonitorCache<TOptions> where TOptions : class
189209
{
@@ -399,4 +419,67 @@ public ValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3
399419
public System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, TDep5, bool> Validation { get { throw null; } }
400420
public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; }
401421
}
422+
public partial class AsyncValidateOptions<TOptions> : Microsoft.Extensions.Options.IAsyncValidateOptions<TOptions> where TOptions : class
423+
{
424+
public AsyncValidateOptions(string? name, System.Func<TOptions, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) { }
425+
public string FailureMessage { get { throw null; } }
426+
public string? Name { get { throw null; } }
427+
public System.Func<TOptions, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> Validation { get { throw null; } }
428+
public System.Threading.Tasks.Task<Microsoft.Extensions.Options.ValidateOptionsResult> ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
429+
}
430+
public partial class AsyncValidateOptions<TOptions, TDep> : Microsoft.Extensions.Options.IAsyncValidateOptions<TOptions> where TOptions : class
431+
{
432+
public AsyncValidateOptions(string? name, TDep dependency, System.Func<TOptions, TDep, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) { }
433+
public TDep Dependency { get { throw null; } }
434+
public string FailureMessage { get { throw null; } }
435+
public string? Name { get { throw null; } }
436+
public System.Func<TOptions, TDep, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> Validation { get { throw null; } }
437+
public System.Threading.Tasks.Task<Microsoft.Extensions.Options.ValidateOptionsResult> ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
438+
}
439+
public partial class AsyncValidateOptions<TOptions, TDep1, TDep2> : Microsoft.Extensions.Options.IAsyncValidateOptions<TOptions> where TOptions : class
440+
{
441+
public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, System.Func<TOptions, TDep1, TDep2, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) { }
442+
public TDep1 Dependency1 { get { throw null; } }
443+
public TDep2 Dependency2 { get { throw null; } }
444+
public string FailureMessage { get { throw null; } }
445+
public string? Name { get { throw null; } }
446+
public System.Func<TOptions, TDep1, TDep2, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> Validation { get { throw null; } }
447+
public System.Threading.Tasks.Task<Microsoft.Extensions.Options.ValidateOptionsResult> ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
448+
}
449+
public partial class AsyncValidateOptions<TOptions, TDep1, TDep2, TDep3> : Microsoft.Extensions.Options.IAsyncValidateOptions<TOptions> where TOptions : class
450+
{
451+
public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 dependency3, System.Func<TOptions, TDep1, TDep2, TDep3, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) { }
452+
public TDep1 Dependency1 { get { throw null; } }
453+
public TDep2 Dependency2 { get { throw null; } }
454+
public TDep3 Dependency3 { get { throw null; } }
455+
public string FailureMessage { get { throw null; } }
456+
public string? Name { get { throw null; } }
457+
public System.Func<TOptions, TDep1, TDep2, TDep3, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> Validation { get { throw null; } }
458+
public System.Threading.Tasks.Task<Microsoft.Extensions.Options.ValidateOptionsResult> ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
459+
}
460+
public partial class AsyncValidateOptions<TOptions, TDep1, TDep2, TDep3, TDep4> : Microsoft.Extensions.Options.IAsyncValidateOptions<TOptions> where TOptions : class
461+
{
462+
public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 dependency3, TDep4 dependency4, System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) { }
463+
public TDep1 Dependency1 { get { throw null; } }
464+
public TDep2 Dependency2 { get { throw null; } }
465+
public TDep3 Dependency3 { get { throw null; } }
466+
public TDep4 Dependency4 { get { throw null; } }
467+
public string FailureMessage { get { throw null; } }
468+
public string? Name { get { throw null; } }
469+
public System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> Validation { get { throw null; } }
470+
public System.Threading.Tasks.Task<Microsoft.Extensions.Options.ValidateOptionsResult> ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
471+
}
472+
public partial class AsyncValidateOptions<TOptions, TDep1, TDep2, TDep3, TDep4, TDep5> : Microsoft.Extensions.Options.IAsyncValidateOptions<TOptions> where TOptions : class
473+
{
474+
public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2, TDep3 dependency3, TDep4 dependency4, TDep5 dependency5, System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, TDep5, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> validation, string failureMessage) { }
475+
public TDep1 Dependency1 { get { throw null; } }
476+
public TDep2 Dependency2 { get { throw null; } }
477+
public TDep3 Dependency3 { get { throw null; } }
478+
public TDep4 Dependency4 { get { throw null; } }
479+
public TDep5 Dependency5 { get { throw null; } }
480+
public string FailureMessage { get { throw null; } }
481+
public string? Name { get { throw null; } }
482+
public System.Func<TOptions, TDep1, TDep2, TDep3, TDep4, TDep5, System.Threading.CancellationToken, System.Threading.Tasks.Task<bool>> Validation { get { throw null; } }
483+
public System.Threading.Tasks.Task<Microsoft.Extensions.Options.ValidateOptionsResult> ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
484+
}
402485
}

0 commit comments

Comments
 (0)