Skip to content

[API Proposal]: Async Validation Support for System.ComponentModel.DataAnnotations #128096

Closed

Description

@ViveliDuCh

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:

  1. A Minimal API endpoint validating a registration form: checking username uniqueness requires a database round-trip that blocks a thread pool thread.
  2. 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.
  3. 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

  1. IAsyncValidatableObject.ValidateAsync return type: Uses IAsyncEnumerable<ValidationResult> (streaming). Enables progressive UI display in Blazor and component vendor ecosystems. See comparison assessment.
  2. GetValidationResultAsync placement: Confirmed on AsyncValidationAttribute only, not on base ValidationAttribute. The Validator handles is AsyncValidationAttribute dispatch internally.
  3. 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.
  4. 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

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

Activity

  1. added this to the 11.0.0 milestone on May 12, 2026
  2. self-assigned this
    on May 12, 2026
  3. added
    api-suggestionEarly API idea and discussion, it is NOT ready for implementation
    on May 12, 2026
  4. dotnet-policy-service commented on May 12, 2026

    @dotnet-policy-service
    Contributor

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

  5. tarekgh commented on May 15, 2026

    @tarekgh
    Member

    prefers IAsyncValidatableObject over IValidatableObject

    it's not clear what "prefers" means:

    • If a class implements both, does only IAsyncValidatableObject.ValidateAsync() run?
    • Or do both run?
    • What about the sync TryValidateObject path on a class implementing both?

    This needs explicit specification since it affects both the runtime Validator and the source generator.

  6. tarekgh commented on May 15, 2026

    @tarekgh
    Member

    When someone adds an AsyncValidationAttribute to a model and calls the existing sync Validator.TryValidateObject(), it throws NotSupportedException. This is intentional but is effectively a runtime breaking change for:

    • Libraries that accept user-provided types and validate them synchronously
    • The current Options source generator, which generates sync Validate() calling Validator.TryValidateValue()

    The generated sync Validate() would need to either:

    • Filter out AsyncValidationAttribute instances before calling TryValidateValue(), or
    • The sync TryValidateValue should skip (not throw on) AsyncValidationAttribute and only the async path should run them

    Is it possible we can make the behavior non-breaking?

  7. ViveliDuCh commented on May 15, 2026

    @ViveliDuCh
    MemberAuthor

    it's not clear what "prefers" means

    "Prefers" means exactly one runs, never both. The async path picks IAsyncValidatableObject.ValidateAsync() when available; IValidatableObject.Validate() only runs as a fallback for types that don't implement IAsyncValidatableObject. This applies consistently to the runtime Validator, the Options source generator, and downstream consumers.

    Is it possible we can make the behavior non-breaking?

    @tarekgh Great catch! I agree with both of your suggestions: the simple skip makes async-only attributes invisible to sync callers (non-breaking), and the HasSyncFallback() detection (with reflection) would let dual-mode attributes still run their sync fallback when relevant.

    I'll look into both: the simple skip as baseline, and the reflection-based detection as a refinement.

  8. danroth27 commented on May 15, 2026

    @danroth27
    Member

    When someone adds an AsyncValidationAttribute to a model and calls the existing sync Validator.TryValidateObject(), it throws NotSupportedException. This is intentional but is effectively a runtime breaking change

    I think throwing an exception in this case is preferred. It seems worse to silently not run the async validation logic and risk claiming the object is in a valid state when it may not be.

  9. danroth27 commented on May 15, 2026

    @danroth27
    Member

    I think we also discussed that we want IAsyncValidatableObject to derive from IValidatableObject similar to how AsyncValidationAttribute derives from ValidationAttribute and have the Validate default interface method implementation throw.

  10. ViveliDuCh commented on May 15, 2026

    @ViveliDuCh
    MemberAuthor

    Update after further discussion: I'm aligning on throw as the default, consistent with the gist's design. The skip approach would risk exactly the "silently not run async validation" problem pointed out above.

    On @tarekgh's dual-mode concern: looking more closely at the existing design, this is actually already handled. AsyncValidationAttribute.IsValid() is virtual (not sealed), subclasses that override it with a sync fallback get that override called via normal virtual dispatch through the existing Validator.GetValidationErrors() path. No extra detection is needed:

    • Async-only attributes (no IsValid override) hit the base AsyncValidationAttribute.IsValid() which throws NotSupportedException: clear signal to use async APIs.
    • Dual-mode attributes (override IsValid with sync logic) run their sync fallback normally: the Validator never sees the throw.

    *This is the pattern shown in Scenario 4 of the proposal (AsyncDateRangeValidWithSyncFallback).

  11. tarekgh commented on May 15, 2026

    @tarekgh
    Member

    I think throwing an exception in this case is preferred. It seems worse to silently not run the async validation logic and risk claiming the object is in a valid state when it may not be.

    This means lazy option validations through IOpeion<T>.Value can never be used for any option objects having async attributes. I am not sure how breaking this can be.

     public class MyService
     {
         public MyService(IOptions<SmtpSettings> options)
         {
             var settings = options.Value;  // ← validation happens HERE
         }
     }

    do we need to introduce something like the following too? or we don't care about this scenario?

     public interface IAsyncOptions<T> where T : class
     {
         ValueTask<T> GetAsync(CancellationToken ct = default);
     }
  12. danroth27 commented on May 15, 2026

    @danroth27
    Member

    IAsyncValidatableObject return type: ValueTask<IEnumerable<>> vs IAsyncEnumerable<>

    Is it fair to say that most known validation consumers want the fully set of validation errors, not one-by-one delivery? It seems like our existing in-box consumers all operate that way.

  13. danroth27 commented on May 15, 2026

    @danroth27
    Member

    This means lazy option validations through IOption<T>.Value can never be used for any option objects having async attributes. I am not sure how breaking this can be.

    That sounds like the right behavior to me.

  14. jeffhandley commented on May 22, 2026

    @jeffhandley
    Member

    @ViveliDuCh I recommend changing from NotSupportedException to InvalidOperationException as it seems more fitting:

    The exception that is thrown when a method call is invalid for the object's current state.

    The synchronous validation method call is invalid when the object's current state includes async validators.

  15. 2 remaining items

  16. bartonjs commented on May 26, 2026

    @bartonjs
    Member

    Video

    • Rather than have IsValid throw, make it abstract override so the user is forced to decide how to be handled by sync callers.
    • Changed AsyncValidationAttribute async members to regular Task instead of ValueTask
    • Removed IAsyncValidatableObject.IValidatableObject.Validate DIM for parity with abstract IsValid
    • Discussed whether the Validator methods needed a concurrent collection or if ICollection was good enough, decided ICollection was good enough
    • Changed all Validator ValueTask to Task
    namespace System.ComponentModel.DataAnnotations;
    
    // New abstract class deriving from ValidationAttribute
    public abstract class AsyncValidationAttribute : ValidationAttribute
    {
        protected AsyncValidationAttribute();
        protected AsyncValidationAttribute(Func<string> errorMessageAccessor);
        protected AsyncValidationAttribute(string errorMessage);
    
        protected abstract override ValidationResult? IsValid(object? value, ValidationContext validationContext);
    
        protected abstract Task<ValidationResult?> IsValidAsync(
            object? value,
            ValidationContext validationContext,
            CancellationToken cancellationToken);
    
        public Task<ValidationResult?> GetValidationResultAsync(
            object? value,
            ValidationContext validationContext,
            CancellationToken cancellationToken = default);
    }
    
    public interface IAsyncValidatableObject : IValidatableObject
    {
        IAsyncEnumerable<ValidationResult> ValidateAsync(
            ValidationContext validationContext,
            CancellationToken cancellationToken = default);
    }
    
    public static partial class Validator
    {
        public static Task<bool> TryValidateObjectAsync(
            object instance,
            ValidationContext validationContext,
            ICollection<ValidationResult>? validationResults,
            CancellationToken cancellationToken = default);
    
        public static Task<bool> TryValidateObjectAsync(
            object instance,
            ValidationContext validationContext,
            ICollection<ValidationResult>? validationResults,
            bool validateAllProperties,
            CancellationToken cancellationToken = default);
    
        public static Task<bool> TryValidatePropertyAsync(
            object? value,
            ValidationContext validationContext,
            ICollection<ValidationResult>? validationResults,
            CancellationToken cancellationToken = default);
    
        public static Task<bool> TryValidateValueAsync(
            object? value,
            ValidationContext validationContext,
            ICollection<ValidationResult>? validationResults,
            IEnumerable<ValidationAttribute> validationAttributes,
            CancellationToken cancellationToken = default);
    
        public static Task ValidateObjectAsync(
            object instance,
            ValidationContext validationContext,
            CancellationToken cancellationToken = default);
    
        public static Task ValidateObjectAsync(
            object instance,
            ValidationContext validationContext,
            bool validateAllProperties,
            CancellationToken cancellationToken = default);
    
        public static Task ValidatePropertyAsync(
            object? value,
            ValidationContext validationContext,
            CancellationToken cancellationToken = default);
    
        public static Task ValidateValueAsync(
            object? value,
            ValidationContext validationContext,
            IEnumerable<ValidationAttribute> validationAttributes,
            CancellationToken cancellationToken = default);
    }
  17. added
    api-approvedAPI was approved in API review, it can be implemented
    and removed
    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
    on May 26, 2026
  18. Youssef1313 commented on May 27, 2026

    @Youssef1313
    Member

    Note: consider preventing IsValid(object) from being overridden.

    public sealed override bool IsValid(object? value)
        => IsValid(value, null) == ValidationResult.Success;
  19. Lanayx commented on May 27, 2026

    @Lanayx

    I know it's a not very relevant to this topic (or maybe relevant?), but as soon as new APIs for DataAnnotations are developed, it would be good to add recursive validation, developers stumble upon it for decades. Previous attempts were rejected with the comment that new API is required, so I think it's a good time to revisit it, since new APIs are being developed anyway.
    And BTW, async validation can also be recursive

  20. ViveliDuCh commented on May 29, 2026

    @ViveliDuCh
    MemberAuthor

    cc @bartonjs @halter73
    Agreeing with @Youssef1313's earlier suggestion in #128096 (comment):

    public sealed override bool IsValid(object? value)
        => IsValid(value, null) == ValidationResult.Success;

    I added a sealed override of the 1-arg IsValid on AsyncValidationAttribute, reinforcing that implementors only provide the ValidationContext overload.

    I’ll proceed with this direction unless there are concerns.

  21. halter73 commented on May 30, 2026

    @halter73
    Member

    I agree with @Youssef1313. Sealing the non-ValidationContext overload seems like pure goodness.

  22. locked and limited conversation to collaborators on Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions