DEV Community

JKC
JKC

Posted on

Stop Blocking on the Database Inside IsValid. .NET 11 Validates Async

TL;DR: .NET 11 lets DataAnnotations rules await things. Derive from AsyncValidationAttribute, or implement IAsyncValidatableObject, and call Validator.TryValidateObjectAsync. .NET 11 is a release candidate right now: go-live licensed, but not final yet.

The line nobody wants to touch

"Is this email already taken?" is a validation rule. It is also a database query. DataAnnotations has always been synchronous, so the rule ends up looking like this:

sealed class UniqueEmailAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        // The database call is async. The validator is not. So...
        bool taken = UserStore.IsTakenAsync((string)value!).GetAwaiter().GetResult();
        return taken ? new ValidationResult("That email is already taken.") : ValidationResult.Success;
    }
}
Enter fullscreen mode Exit fullscreen mode

It works. It ran fine on .NET 10 and correctly said the email was taken. It also parks a thread-pool thread for the whole query, which is fine right up until a lot of people sign up at once. So most teams move the check out of the model, into the controller, and now the rules live in two places.

Meet AsyncValidationAttribute

.NET 11 adds three things to System.ComponentModel.DataAnnotations:

  • AsyncValidationAttribute, a base class with IsValidAsync.
  • IAsyncValidatableObject, whose ValidateAsync returns an IAsyncEnumerable<ValidationResult>.
  • Validator.TryValidateObjectAsync, ValidateObjectAsync, ValidatePropertyAsync, and ValidateValueAsync.

Here is the same rule, without the hostage situation:

[AttributeUsage(AttributeTargets.Property)]
sealed class UniqueAttribute(string field) : AsyncValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) =>
        throw new InvalidOperationException("[Unique] needs the async validator APIs.");

    protected override async Task<ValidationResult?> IsValidAsync(
        object? value, ValidationContext validationContext, CancellationToken cancellationToken)
    {
        if (value is not string s) return ValidationResult.Success;
        var store = (UserStore)validationContext.GetService(typeof(UserStore))!;
        bool taken = await store.IsTakenAsync(field, s, cancellationToken);
        return taken
            ? new ValidationResult($"That {field} is already taken.", [validationContext.MemberName!])
            : ValidationResult.Success;
    }
}
Enter fullscreen mode Exit fullscreen mode

The synchronous IsValid is still abstract, so you have to decide what it does. Throwing is what the ASP.NET Core docs recommend for a rule that only works async, and gotcha 2 shows why.

Use it like any other attribute:

class SignUp
{
    [Required, EmailAddress, Unique("email")]
    public string? Email { get; set; }

    [Required, Unique("handle")]
    public string? Handle { get; set; }
}

var form = new SignUp { Email = "taken@example.com", Handle = "neo" };
var results = new List<ValidationResult>();
var ctx = new ValidationContext(form, services, null);

bool ok = await Validator.TryValidateObjectAsync(form, ctx, results, validateAllProperties: true);
Enter fullscreen mode Exit fullscreen mode
valid=False
  Email: That email is already taken.
Enter fullscreen mode Exit fullscreen mode

services is any IServiceProvider; in ASP.NET Core it is the request's container. In my test, the fake email lookup waited 200 ms and the handle lookup waited 600 ms. The whole validation took 608 ms, because the two properties were checked at the same time.

A realistic one: the booking form

Rules that span fields go on the object. Check-out after check-in is a plain comparison; "is the room free" needs the database:

using System.Runtime.CompilerServices; // for [EnumeratorCancellation]

class Booking : IAsyncValidatableObject
{
    [Required] public string? Room { get; set; }
    public DateOnly From { get; set; }
    public DateOnly To { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) =>
        throw new InvalidOperationException("Booking validates asynchronously. Use Validator.TryValidateObjectAsync.");

    public async IAsyncEnumerable<ValidationResult> ValidateAsync(
        ValidationContext validationContext,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        if (To <= From)
            yield return new ValidationResult("Check-out must be after check-in.", [nameof(To)]);

        await Task.Delay(50, cancellationToken); // the availability query
        if (Room == "Blue")
            yield return new ValidationResult("Blue is fully booked for those dates.", [nameof(Room)]);
    }
}
Enter fullscreen mode Exit fullscreen mode

With Room = "Blue" and the dates backwards, TryValidateObjectAsync returned both errors:

Check-out must be after check-in. | Blue is fully booked for those dates.
Enter fullscreen mode Exit fullscreen mode

The synchronous Validate was called zero times. On the async path, the validator calls only ValidateAsync, so the plain date check has to live in there too.

Can I use it?

Where What you get
System.ComponentModel.DataAnnotations AsyncValidationAttribute, IAsyncValidatableObject, async Validator methods
Minimal APIs (AddValidation()) Runs async rules before the endpoint
Blazor DataAnnotationsValidator Awaits async rules on EditForm submit
Microsoft.Extensions.Options Async options validation, and IAsyncStartupValidator
  • The base library APIs arrived in .NET 11 Preview 6, per the ASP.NET Core release notes.
  • .NET 11 RC2 shipped on October 13, 2026 with a go-live support license. It is still a release candidate.
  • On the .NET 10 SDK, the attribute fails with CS0246: The type or namespace name 'AsyncValidationAttribute' could not be found.

I ran every snippet here as a file-based app with dotnet run file.cs on SDK 11.0.100-rc.1, on Windows.

Gotchas

1. validateAllProperties still defaults to "not really". TryValidateObjectAsync(form, ctx, results) without the flag checks only [Required] and type-level rules. For the taken email above, it answered valid=True with zero errors. Same rule as the sync API, and just as easy to forget.

2. The sync API does not skip your async rule. Validator.TryValidateObject on the same form called the sync IsValid, which threw InvalidOperationException: [Unique] needs the async validator APIs. That is the point of throwing: a silent Success would let the taken email through.

3. Pass null for the results to fail fast. With a results list, a form where both checks fail took 605 to 608 ms and collected two errors. With null, it stopped at the first failure in 200 to 214 ms, and the 600 ms handle lookup saw its token cancelled. So honor the CancellationToken, or the shortcut waits for you anyway.

4. Stages still run in order. Property rules first, then type-level attributes, then the object. With Room empty, the result was only The Room field is required., and ValidateAsync never ran. Your users will meet the date error on their second try.

5. File-based apps complain about trimming. File-based apps default to PublishAot=true, and every ValidationContext constructor and Validator call raised IL2026. I added #:property PublishAot=false to the test file. In a trimmed app, take the warning seriously: it says the context uses reflection to find your type's display names.

Wrap-up

The async check finally lives next to [Required], where everybody looks for it. Go find the .GetAwaiter().GetResult() in your validators and give it the day off.

Sources

This article was written with the help of AI and checked against the sources above.

Top comments (0)