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;
}
}
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 withIsValidAsync. -
IAsyncValidatableObject, whoseValidateAsyncreturns anIAsyncEnumerable<ValidationResult>. -
Validator.TryValidateObjectAsync,ValidateObjectAsync,ValidatePropertyAsync, andValidateValueAsync.
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;
}
}
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);
valid=False
Email: That email is already taken.
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)]);
}
}
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.
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
- What's new in .NET libraries for .NET 11: asynchronous validation with DataAnnotations (Microsoft Learn)
- AsyncValidationAttribute class (Microsoft Learn)
- IAsyncValidatableObject interface (Microsoft Learn)
- Validator.TryValidateObjectAsync method (Microsoft Learn)
- What's new in ASP.NET Core in .NET 11: async validation for Minimal APIs and Blazor (Microsoft Learn)
- Announcing .NET 11 Release Candidate 2 (.NET Blog)
This article was written with the help of AI and checked against the sources above.
Top comments (0)