Background and motivation
System.ComponentModel.DataAnnotations validation has been synchronous since its introduction in .NET Framework 3.5 SP1 (2008). The Validator class, ValidationAttribute.IsValid, IValidatableObject, and ValidationContext (all added in .NET Framework 4.0) form a fully synchronous pipeline. Across the .NET product suite, DataAnnotations has been integrated into 11 distinct application models: MVC, Blazor, Options, EF Core conventions, OpenAPI schema, Minimal APIs via Microsoft.Extensions.Validation, CommunityToolkit.Mvvm ObservableValidator, the Options validation source generator, .NET Aspire, and the foundational Validator class itself. Every one is synchronous at the DataAnnotations level.
Modern applications frequently need to validate against external resources (database uniqueness checks, async API calls) and today's only option is blocking I/O inside IsValid.
Concrete scenarios:
- A Minimal API endpoint validating a registration form: checking username uniqueness requires a database round-trip that blocks a thread pool thread.
- A Blazor Server form where blocking I/O inside validation freezes the UI because
EditContext.Validate() is synchronous. Blazor's component model is inherently async, and async validation was explicitly planned in 2019 but never implemented.
- An Options startup validator (
ValidateOnStart) that checks a connection string is reachable. Blocking at startup delays app readiness.
Architecture note:
- ASP.NET Core MVC does not use
Validator.TryValidateObject(). It has its own pipeline via DataAnnotationsModelValidator → ValidationAttribute.GetValidationResult(). Changes to Validator alone do not automatically benefit MVC.
- Meanwhile,
Microsoft.Extensions.Validation (.NET 10) is async at the orchestration level but calls IsValid() synchronously at the leaf which makes it the closest to async-ready.
Prior art: The oroztocil/validation-demo branch in dotnet/aspnetcore prototyped AsyncValidationAttribute and IAsyncValidatableObject in Microsoft.Extensions.Validation to prove the pipeline could handle async. This proposal moves the canonical types into the core System.ComponentModel.Annotations library so all downstream consumers converge on a single async validation model.
References:
API Proposal
Note: This API surface matches the feasibility prototype.
namespace System.ComponentModel.DataAnnotations;
// New abstract class deriving from ValidationAttribute
public abstract partial class AsyncValidationAttribute : ValidationAttribute
{
protected AsyncValidationAttribute();
protected AsyncValidationAttribute(Func<string> errorMessageAccessor);
protected AsyncValidationAttribute(string errorMessage);
// Sync IsValid throws InvalidOperationException, forcing callers to use the async path.
// Virtual (not sealed): subclasses may override to provide a sync fallback.
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext);
// Async override point for subclasses
protected abstract ValueTask<ValidationResult?> IsValidAsync(
object? value,
ValidationContext validationContext,
CancellationToken cancellationToken);
// Public async entry point, counterpart to GetValidationResult.
// Calls IsValidAsync, populates error message via FormatErrorMessage on null/empty.
public ValueTask<ValidationResult?> GetValidationResultAsync(
object? value,
ValidationContext validationContext,
CancellationToken cancellationToken = default);
}
// New interface for object-level async validation.
// Inherits from IValidatableObject with a DIM that throws InvalidOperationException,
// mirroring the AsyncValidationAttribute pattern where sync paths fail clearly
// rather than silently skipping async validation.
public partial interface IAsyncValidatableObject : IValidatableObject
{
IEnumerable<ValidationResult> IValidatableObject.Validate(
ValidationContext validationContext) =>
throw new InvalidOperationException(
"This object implements IAsyncValidatableObject and supports only " +
"asynchronous validation. Use the async Validator methods.");
IAsyncEnumerable<ValidationResult> ValidateAsync(
ValidationContext validationContext,
CancellationToken cancellationToken = default);
}
// Async counterparts on the existing Validator static class
public static partial class Validator
{
// Existing sync methods (unchanged)
public static bool TryValidateObject(object instance, ValidationContext validationContext, ICollection<ValidationResult>? validationResults);
public static bool TryValidateObject(object instance, ValidationContext validationContext, ICollection<ValidationResult>? validationResults, bool validateAllProperties);
public static bool TryValidateProperty(object? value, ValidationContext validationContext, ICollection<ValidationResult>? validationResults);
public static bool TryValidateValue(object? value, ValidationContext validationContext, ICollection<ValidationResult>? validationResults, IEnumerable<ValidationAttribute> validationAttributes);
public static void ValidateObject(object instance, ValidationContext validationContext);
public static void ValidateObject(object instance, ValidationContext validationContext, bool validateAllProperties);
public static void ValidateProperty(object? value, ValidationContext validationContext);
public static void ValidateValue(object? value, ValidationContext validationContext, IEnumerable<ValidationAttribute> validationAttributes);
// New async methods
public static ValueTask<bool> TryValidateObjectAsync(
object instance,
ValidationContext validationContext,
ICollection<ValidationResult>? validationResults,
CancellationToken cancellationToken = default);
// validateAllProperties: when true, validates all properties; when false, only [Required] properties.
public static ValueTask<bool> TryValidateObjectAsync(
object instance,
ValidationContext validationContext,
ICollection<ValidationResult>? validationResults,
bool validateAllProperties,
CancellationToken cancellationToken = default);
public static ValueTask<bool> TryValidatePropertyAsync(
object? value,
ValidationContext validationContext,
ICollection<ValidationResult>? validationResults,
CancellationToken cancellationToken = default);
public static ValueTask<bool> TryValidateValueAsync(
object? value,
ValidationContext validationContext,
ICollection<ValidationResult>? validationResults,
IEnumerable<ValidationAttribute> validationAttributes,
CancellationToken cancellationToken = default);
public static ValueTask ValidateObjectAsync(
object instance,
ValidationContext validationContext,
CancellationToken cancellationToken = default);
// validateAllProperties: when true, validates all properties; when false, only [Required] properties.
public static ValueTask ValidateObjectAsync(
object instance,
ValidationContext validationContext,
bool validateAllProperties,
CancellationToken cancellationToken = default);
public static ValueTask ValidatePropertyAsync(
object? value,
ValidationContext validationContext,
CancellationToken cancellationToken = default);
public static ValueTask ValidateValueAsync(
object? value,
ValidationContext validationContext,
IEnumerable<ValidationAttribute> validationAttributes,
CancellationToken cancellationToken = default);
}
Sync/async dispatch behavior:
| Attribute type |
Sync path (GetValidationResult) |
Async path (GetValidationResultAsync) |
Traditional ValidationAttribute subclass |
✅ Works normally |
✅ Async Validator delegates to sync IsValid internally |
AsyncValidationAttribute (async-only) |
❌ Throws InvalidOperationException |
✅ Calls IsValidAsync |
AsyncValidationAttribute with sync override |
✅ Uses IsValid override |
✅ Calls IsValidAsync |
Prototype: https://github.com/ViveliDuCh/runtime/tree/async-validation
API Usage
See full samples covering the following scenarios here.
Scenario 1: No interface, mixed async and sync property- and entity-level attributes
A plain class (no IValidatableObject / IAsyncValidatableObject) decorated with both
sync (ValidationAttribute) and async (AsyncValidationAttribute) attributes at the
property and class level. TryValidateObjectAsync runs sync attrs first, then async.
// Sync property attribute (standard)
public class IsValidNameAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
Thread.Sleep(50); // Simulates sync I/O (blocks thread)
return ValidationResult.Success;
}
}
// Async property attribute: checks username availability against a database
public class UsernameAvailableAsyncAttribute : AsyncValidationAttribute
{
public UsernameAvailableAsyncAttribute()
: base("The username is already taken.") { }
protected override async ValueTask<ValidationResult?> IsValidAsync(
object? value, ValidationContext validationContext, CancellationToken cancellationToken)
{
if (value is not string username || string.IsNullOrWhiteSpace(username))
return ValidationResult.Success; // Let [Required] handle nulls
// Simulates a database round-trip to check uniqueness
await Task.Delay(200, cancellationToken);
bool isTaken = username.Equals("admin", StringComparison.OrdinalIgnoreCase);
return isTaken
? new ValidationResult($"The username '{username}' is already taken.",
new[] { validationContext.MemberName! })
: ValidationResult.Success;
}
}
// Async entity-level attribute (applied to the class)
[AttributeUsage(AttributeTargets.Class)]
public class AsyncDateRangeValidAttribute : AsyncValidationAttribute
{
private readonly string _startProp;
private readonly string _endProp;
public AsyncDateRangeValidAttribute(string startProp, string endProp)
{ _startProp = startProp; _endProp = endProp; }
protected override async ValueTask<ValidationResult?> IsValidAsync(
object? value, ValidationContext validationContext, CancellationToken cancellationToken)
{
// Simulates calling a calendar/scheduling service to get max allowed date
await Task.Delay(50, cancellationToken);
DateTime maxDateAllowed = DateTime.UtcNow.AddYears(1); // Service response
var type = validationContext.ObjectType;
var instance = validationContext.ObjectInstance;
var start = (DateTime?)type.GetProperty(_startProp)?.GetValue(instance);
var end = (DateTime?)type.GetProperty(_endProp)?.GetValue(instance);
if (start.HasValue && end.HasValue && start.Value >= end.Value)
return new ValidationResult($"'{_startProp}' must be before '{_endProp}'.",
new[] { _startProp, _endProp });
if (end.HasValue && end.Value > maxDateAllowed)
return new ValidationResult(
$"'{_endProp}' cannot be later than {maxDateAllowed:d} (service limit).",
new[] { _endProp });
return ValidationResult.Success;
}
}
// Model: sync + async property attrs, async class-level attr, NO interface
[AsyncDateRangeValid(nameof(StartDate), nameof(EndDate))]
public class Event
{
[Required] // sync property attr
public string? Title { get; set; }
[Required] // sync property attr
public DateTime? StartDate { get; set; }
[Required] // sync property attr
public DateTime? EndDate { get; set; }
}
public class User
{
[Required] // sync property attr
[IsValidName] // sync property attr (Thread.Sleep)
public string? Name { get; set; }
[Required] // sync property attr
[UsernameAvailableAsync] // async property attr (DB round-trip)
public string? Username { get; set; }
}
// Validation: three-phase (sync attrs first, async attrs in parallel, then object-level)
var user = new User { Name = "Bob", Username = "admin" };
var results = new List<ValidationResult>();
bool valid = await Validator.TryValidateObjectAsync(
user, new ValidationContext(user), results, validateAllProperties: true);
// Phase 1: All properties validated in parallel. Per property: sync attrs first
// Phase 2: Per property: [UsernameAvailableAsync] runs asynchronously (parallel across properties)
// Phase 3: IAsyncValidatableObject / IValidatableObject (if any)
// valid == false, results: "The username 'admin' is already taken."
// Two-phase optimization: sync failure skips async entirely
var badUser = new User { Name = "", Username = "admin" }; // [Required] fails
results.Clear();
valid = await Validator.TryValidateObjectAsync(
badUser, new ValidationContext(badUser), results, true);
// [Required] fails on Name → [UsernameAvailableAsync] never runs → no I/O wasted
Scenario 2: IValidatableObject with mixed async and sync attributes
A class that implements the existing sync IValidatableObject interface alongside
both sync and async property-level attributes. TryValidateObjectAsync runs
property-level attrs (sync then async), then calls IValidatableObject.Validate().
public class Order : IValidatableObject
{
[Required] // sync property attr
public string? ProductName { get; set; }
[Required] // sync property attr
[Range(1, 10_000)] // sync property attr
public int Quantity { get; set; }
[Required] // sync property attr
[Range(0.01, double.MaxValue)] // sync property attr
public decimal UnitPrice { get; set; }
// IValidatableObject.Validate: sync cross-property logic
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
Thread.Sleep(50); // Simulates sync inventory check (blocks thread)
decimal totalCost = Quantity * UnitPrice;
if (totalCost > 50_000m)
{
yield return new ValidationResult(
$"Total cost ({totalCost:C}) exceeds the $50,000 limit.",
new[] { nameof(Quantity), nameof(UnitPrice) });
}
}
}
// TryValidateObjectAsync works with IValidatableObject, calling Validate() synchronously
// after property-level validation passes. Property validation runs in parallel across properties.
var order = new Order { ProductName = "Widget", Quantity = 10_000, UnitPrice = 10m };
var results = new List<ValidationResult>();
bool valid = await Validator.TryValidateObjectAsync(
order, new ValidationContext(order), results, true);
// Phase 1: sync property attrs validated in parallel across all properties → pass
// Phase 2: no async property attrs → skipped
// IValidatableObject.Validate() runs → total $100k > $50k → fails
Scenario 3: IAsyncValidatableObject with mixed async and sync attributes
A class that implements the new IAsyncValidatableObject interface for async
cross-property validation, decorated with both sync and async property-level attributes.
public class MoneyTransfer : IAsyncValidatableObject
{
[Required] // sync property attr
public string? FromAccount { get; set; }
[Required] // sync property attr
public string? ToAccount { get; set; }
[Range(0.01, double.MaxValue)] // sync property attr
public decimal Amount { get; set; }
// IAsyncValidatableObject.ValidateAsync: async cross-property logic (streaming)
public async IAsyncEnumerable<ValidationResult> ValidateAsync(
ValidationContext validationContext,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// Sync cross-property check (no I/O needed)
if (FromAccount == ToAccount)
{
yield return new ValidationResult(
"Cannot transfer to the same account.",
new[] { nameof(FromAccount), nameof(ToAccount) });
}
// Async balance check (frees the thread)
await Task.Delay(50, cancellationToken);
decimal balance = 500.00m;
if (Amount > balance)
{
yield return new ValidationResult(
$"Insufficient funds. Balance: ${balance:F2}, Transfer: ${Amount:F2}.",
new[] { nameof(Amount) });
}
}
}
// TryValidateObjectAsync prefers IAsyncValidatableObject over IValidatableObject
var transfer = new MoneyTransfer
{
FromAccount = "checking", ToAccount = "checking", Amount = 1000.00m
};
var results = new List<ValidationResult>();
bool valid = await Validator.TryValidateObjectAsync(
transfer, new ValidationContext(transfer), results, true);
// Phase 1: sync [Required]/[Range] validated in parallel across properties → pass
// Phase 2: no async property attrs → skipped
// IAsyncValidatableObject.ValidateAsync() runs:
// → same account error + insufficient funds error
Scenario 4: Async attribute with sync fallback (sync-over-async via Task.Result)
An AsyncValidationAttribute that also overrides the sync IsValid for backward
compatibility with sync callers (e.g., Validator.TryValidateObject). The sync path
uses .GetAwaiter().GetResult(), blocking but functional.
[AttributeUsage(AttributeTargets.Class)]
public class AsyncDateRangeValidWithSyncFallback : AsyncValidationAttribute
{
private readonly string _startProp;
private readonly string _endProp;
public AsyncDateRangeValidWithSyncFallback(string startProp, string endProp)
{ _startProp = startProp; _endProp = endProp; }
// Async path: used by TryValidateObjectAsync (non-blocking)
protected override async ValueTask<ValidationResult?> IsValidAsync(
object? value, ValidationContext validationContext, CancellationToken cancellationToken)
{
await Task.Delay(50, cancellationToken); // Simulates async calendar check
return ValidateDateRange(validationContext);
}
// Sync fallback: used by TryValidateObject (blocks the thread)
// Overrides the base AsyncValidationAttribute.IsValid which throws InvalidOperationException
protected override ValidationResult? IsValid(
object? value, ValidationContext validationContext)
{
// Sync-over-async: blocks the calling thread via .Result
// This is intentional for backward compat with sync-only callers
Thread.Sleep(50); // Simulates sync calendar check
return ValidateDateRange(validationContext);
}
// Shared validation logic (no I/O)
private ValidationResult? ValidateDateRange(ValidationContext validationContext)
{
var type = validationContext.ObjectType;
var instance = validationContext.ObjectInstance;
var start = (DateTime?)type.GetProperty(_startProp)?.GetValue(instance);
var end = (DateTime?)type.GetProperty(_endProp)?.GetValue(instance);
return start.HasValue && end.HasValue && start.Value >= end.Value
? new ValidationResult($"'{_startProp}' must be before '{_endProp}'.",
new[] { _startProp, _endProp })
: ValidationResult.Success;
}
}
// Usage on a model
[AsyncDateRangeValidWithSyncFallback(nameof(StartDate), nameof(EndDate))]
public class Event
{
[Required]
public string? Title { get; set; }
[Required]
public DateTime? StartDate { get; set; }
[Required]
public DateTime? EndDate { get; set; }
}
var badEvent = new Event
{
Title = "Party", StartDate = new DateTime(2026, 12, 25), EndDate = new DateTime(2026, 12, 20)
};
// Async path: non-blocking
var results = new List<ValidationResult>();
bool valid = await Validator.TryValidateObjectAsync(
badEvent, new ValidationContext(badEvent), results, true);
// Calls IsValidAsync → await Task.Delay → returns error
// Sync path: works too (blocks thread, but doesn't throw InvalidOperationException)
results.Clear();
valid = Validator.TryValidateObject(
badEvent, new ValidationContext(badEvent), results, true);
// Calls IsValid (sync override) → Thread.Sleep → returns same error
// CONTRAST: an async-only attribute (no sync override) throws on the sync path:
// Validator.TryValidateObject(userWithAsyncOnlyAttr, ...) → InvalidOperationException
Alternative Designs
Option A: Virtual IsValidAsync on ValidationAttribute directly (no subclass)
- Reflection-based override detection is fragile, and a virtual
IsValidAsync that throws by default confuses the existing 200+ ValidationAttribute subclasses.
Option B: Separate AsyncValidationAttribute NOT deriving from ValidationAttribute
- Sync
Validator.TryValidateObject uses GetCustomAttributes<ValidationAttribute>() and would silently skip it (not desired).
Option C (chosen): AsyncValidationAttribute deriving from ValidationAttribute
- The sync
IsValid override throws InvalidOperationException, forcing async callers. Since AsyncValidationAttribute IS-A ValidationAttribute, sync Validator still discovers it via reflection and produces a clear error.
Option D: IAsyncValidationAttribute interface
- Less discoverable. Users must know to implement an interface AND inherit
ValidationAttribute. The subclass approach is more idiomatic for DataAnnotations.
IAsyncValidatableObject.ValidateAsync return type: Task<IEnumerable<ValidationResult>> instead of IAsyncEnumerable<ValidationResult>
- As noted in the design gist,
IAsyncEnumerable<> allows streaming results, but the alternative Task<IEnumerable<ValidationResult>> is simpler and may be sufficient for most scenarios. Streaming is most beneficial for progressive UI display (e.g., Blazor forms showing errors as they arrive), but many consumers will simply await all results at once. Task<IEnumerable<>> avoids the IAsyncEnumerable dependency and is easier to implement for attribute authors who only need a single async check. The current proposal chooses IAsyncEnumerable for flexibility, but this simpler alternative remains viable if streaming is deemed unnecessary for V1.
Notes/Risks
- The new
Validator.*Async methods follow the established XAsync naming pattern with distinct signatures (return ValueTask). No ambiguity with existing sync methods. All additions are additive, no existing APIs changed.
- Sync
Validator.TryValidateObject discovering an AsyncValidationAttribute will throw InvalidOperationException instead of silently succeeding. This is by design: it surfaces the mismatch between sync callers and async-only attributes.
- Async validators run concurrently across properties and in parallel per property. If any sync attribute fails, async attributes on that property are skipped (no wasted I/O). Validators must not rely on execution order and must be safe for concurrent execution.
ValueTask rationale: All async validation APIs return ValueTask<T> (or ValueTask for throwing variants). IsValidAsync and GetValidationResultAsync are leaf APIs called once per attribute per value — ValueTask avoids a Task allocation when validators complete synchronously (e.g., cached lookups). Validator.TryValidateObjectAsync and related methods are infrastructure APIs consumed via a single await by most callers; orchestration layers (source generator, Options startup) use .AsTask() for Task.WhenAll composition internally. See analysis.
- Scope: This proposal covers the core
System.ComponentModel.DataAnnotations APIs (Phase 1). Downstream consumers (M.E.Validation, Blazor, Options, MVC) adopt independently per the design gist and integration point analysis. MVC is explicitly deferred; sync-only consumers that encounter async-only attributes get a clear error directing them to the async APIs.
Resolved Items
IAsyncValidatableObject.ValidateAsync return type: Uses IAsyncEnumerable<ValidationResult> (streaming). Enables progressive UI display in Blazor and component vendor ecosystems. See comparison assessment.
GetValidationResultAsync placement: Confirmed on AsyncValidationAttribute only, not on base ValidationAttribute. The Validator handles is AsyncValidationAttribute dispatch internally.
ValidationContext.Items thread safety: Items is a read-only input channel by design. No built-in attribute mutates it during validation. The pipeline does not guarantee attribute execution order beyond RequiredAttribute priority. Custom validators should treat Items as read-only; mutations during validation are unsupported. Documented via XML <remarks> on ValidationContext.Items.
- Cross-property short-circuit semantics: When
validationResults is null (breakOnFirstError=true), the first property to complete with errors triggers cooperative cancellation of remaining in-flight async validators via linked CancellationToken. When validationResults is non-null, all properties complete and all errors are collected. Per-property sync-first gating is unconditional.
Open Questions
ValueTask<T> vs Task<T> for async validation methods — leaning ValueTask, acknowledged trade-off
Next Steps
Additional API proposals will build on top of this one but do not block this step. Expected follow-up APIs include messages, helper APIs, and progressive validation support.
UX-Related API Gaps
- Pre-validation rule descriptions: Validation attributes should be able to define/return a message describing the validation rule before execution, so UI can show rules upfront.
- "Validation in progress" messaging: Async validation attributes should provide a message while validation is running, so UX can indicate pending state. Note: Adding more message-related APIs could make validation attribute declarations overly verbose, these concerns are linked.
- Detecting presence of async validators: Possible need for an API to quickly determine whether any async validators are involved, enabling frameworks to choose between sync and async UX paths. Noted as lower priority and possibly deferrable.
- Progressive validation / partial results: Further API will likely be needed after this proposal for more progressive execution (e.g.,
IProgress<ValidationResult> callback, or IAsyncEnumerable return type).
Localization Considerations
- With parallel execution now implemented, thread safety of localization must be considered.
- Reuse existing
ErrorMessage localization patterns (string vs resource-based) for consistency.
- Since async validators run concurrently, error message formatting and resource access must be thread-safe. The existing
FormatErrorMessage pattern is safe (stateless string formatting), but custom validators that access shared mutable state during error message construction must synchronize.
Background and motivation
System.ComponentModel.DataAnnotationsvalidation has been synchronous since its introduction in .NET Framework 3.5 SP1 (2008). TheValidatorclass,ValidationAttribute.IsValid,IValidatableObject, andValidationContext(all added in .NET Framework 4.0) form a fully synchronous pipeline. Across the .NET product suite, DataAnnotations has been integrated into 11 distinct application models: MVC, Blazor, Options, EF Core conventions, OpenAPI schema, Minimal APIs viaMicrosoft.Extensions.Validation, CommunityToolkit.MvvmObservableValidator, the Options validation source generator, .NET Aspire, and the foundationalValidatorclass itself. Every one is synchronous at the DataAnnotations level.Modern applications frequently need to validate against external resources (database uniqueness checks, async API calls) and today's only option is blocking I/O inside
IsValid.Concrete scenarios:
EditContext.Validate()is synchronous. Blazor's component model is inherently async, and async validation was explicitly planned in 2019 but never implemented.ValidateOnStart) that checks a connection string is reachable. Blocking at startup delays app readiness.Architecture note:
Validator.TryValidateObject(). It has its own pipeline viaDataAnnotationsModelValidator→ValidationAttribute.GetValidationResult(). Changes toValidatoralone do not automatically benefit MVC.Microsoft.Extensions.Validation(.NET 10) is async at the orchestration level but callsIsValid()synchronously at the leaf which makes it the closest to async-ready.Prior art: The
oroztocil/validation-demobranch indotnet/aspnetcoreprototypedAsyncValidationAttributeandIAsyncValidatableObjectinMicrosoft.Extensions.Validationto prove the pipeline could handle async. This proposal moves the canonical types into the coreSystem.ComponentModel.Annotationslibrary so all downstream consumers converge on a single async validation model.References:
API Proposal
Sync/async dispatch behavior:
GetValidationResult)GetValidationResultAsync)ValidationAttributesubclassValidatordelegates to syncIsValidinternallyAsyncValidationAttribute(async-only)InvalidOperationExceptionIsValidAsyncAsyncValidationAttributewith sync overrideIsValidoverrideIsValidAsyncPrototype: https://github.com/ViveliDuCh/runtime/tree/async-validation
API Usage
See full samples covering the following scenarios here.
Scenario 1: No interface, mixed async and sync property- and entity-level attributes
A plain class (no
IValidatableObject/IAsyncValidatableObject) decorated with bothsync (
ValidationAttribute) and async (AsyncValidationAttribute) attributes at theproperty and class level.
TryValidateObjectAsyncruns sync attrs first, then async.Scenario 2: IValidatableObject with mixed async and sync attributes
A class that implements the existing sync
IValidatableObjectinterface alongsideboth sync and async property-level attributes.
TryValidateObjectAsyncrunsproperty-level attrs (sync then async), then calls
IValidatableObject.Validate().Scenario 3: IAsyncValidatableObject with mixed async and sync attributes
A class that implements the new
IAsyncValidatableObjectinterface for asynccross-property validation, decorated with both sync and async property-level attributes.
Scenario 4: Async attribute with sync fallback (sync-over-async via Task.Result)
An
AsyncValidationAttributethat also overrides the syncIsValidfor backwardcompatibility with sync callers (e.g.,
Validator.TryValidateObject). The sync pathuses
.GetAwaiter().GetResult(), blocking but functional.Alternative Designs
Option A: Virtual
IsValidAsynconValidationAttributedirectly (no subclass)IsValidAsyncthat throws by default confuses the existing 200+ValidationAttributesubclasses.Option B: Separate
AsyncValidationAttributeNOT deriving fromValidationAttributeValidator.TryValidateObjectusesGetCustomAttributes<ValidationAttribute>()and would silently skip it (not desired).Option C (chosen):
AsyncValidationAttributederiving fromValidationAttributeIsValidoverride throwsInvalidOperationException, forcing async callers. SinceAsyncValidationAttributeIS-AValidationAttribute, syncValidatorstill discovers it via reflection and produces a clear error.Option D:
IAsyncValidationAttributeinterfaceValidationAttribute. The subclass approach is more idiomatic for DataAnnotations.IAsyncValidatableObject.ValidateAsyncreturn type:Task<IEnumerable<ValidationResult>>instead ofIAsyncEnumerable<ValidationResult>IAsyncEnumerable<>allows streaming results, but the alternativeTask<IEnumerable<ValidationResult>>is simpler and may be sufficient for most scenarios. Streaming is most beneficial for progressive UI display (e.g., Blazor forms showing errors as they arrive), but many consumers will simplyawaitall results at once.Task<IEnumerable<>>avoids theIAsyncEnumerabledependency and is easier to implement for attribute authors who only need a single async check. The current proposal choosesIAsyncEnumerablefor flexibility, but this simpler alternative remains viable if streaming is deemed unnecessary for V1.Notes/Risks
Validator.*Asyncmethods follow the establishedXAsyncnaming pattern with distinct signatures (returnValueTask). No ambiguity with existing sync methods. All additions are additive, no existing APIs changed.Validator.TryValidateObjectdiscovering anAsyncValidationAttributewill throwInvalidOperationExceptioninstead of silently succeeding. This is by design: it surfaces the mismatch between sync callers and async-only attributes.ValueTaskrationale: All async validation APIs returnValueTask<T>(orValueTaskfor throwing variants).IsValidAsyncandGetValidationResultAsyncare leaf APIs called once per attribute per value —ValueTaskavoids aTaskallocation when validators complete synchronously (e.g., cached lookups).Validator.TryValidateObjectAsyncand related methods are infrastructure APIs consumed via a singleawaitby most callers; orchestration layers (source generator, Options startup) use.AsTask()forTask.WhenAllcomposition internally. See analysis.System.ComponentModel.DataAnnotationsAPIs (Phase 1). Downstream consumers (M.E.Validation, Blazor, Options, MVC) adopt independently per the design gist and integration point analysis. MVC is explicitly deferred; sync-only consumers that encounter async-only attributes get a clear error directing them to the async APIs.Resolved Items
IAsyncValidatableObject.ValidateAsyncreturn type: UsesIAsyncEnumerable<ValidationResult>(streaming). Enables progressive UI display in Blazor and component vendor ecosystems. See comparison assessment.GetValidationResultAsyncplacement: Confirmed onAsyncValidationAttributeonly, not on baseValidationAttribute. TheValidatorhandlesis AsyncValidationAttributedispatch internally.ValidationContext.Itemsthread safety:Itemsis a read-only input channel by design. No built-in attribute mutates it during validation. The pipeline does not guarantee attribute execution order beyondRequiredAttributepriority. Custom validators should treatItemsas read-only; mutations during validation are unsupported. Documented via XML<remarks>onValidationContext.Items.validationResultsisnull(breakOnFirstError=true), the first property to complete with errors triggers cooperative cancellation of remaining in-flight async validators via linkedCancellationToken. WhenvalidationResultsis non-null, all properties complete and all errors are collected. Per-property sync-first gating is unconditional.Open Questions
ValueTask<T>vsTask<T>for async validation methods — leaningValueTask, acknowledged trade-offNext Steps
Additional API proposals will build on top of this one but do not block this step. Expected follow-up APIs include messages, helper APIs, and progressive validation support.
UX-Related API Gaps
IProgress<ValidationResult>callback, orIAsyncEnumerablereturn type).Localization Considerations
ErrorMessagelocalization patterns (string vs resource-based) for consistency.FormatErrorMessagepattern is safe (stateless string formatting), but custom validators that access shared mutable state during error message construction must synchronize.