# Async Validation in ASP.NET Core 11

> ASP.NET Core 11 adds async validation to DataAnnotations, minimal APIs, and Blazor. Here's how it works, the gotchas to watch for, and how it compares to the FastEndpoints approach.

- URL: https://blog.nimblepros.com/blogs/async-validation-aspnet-core-11/
- Author: [Barret Blake](https://blog.nimblepros.com/authors/barretblake/)
- Published: 2026-11-12
- Categories: [Software Development](https://blog.nimblepros.com/categories/software-development/)
- Publisher: [NimblePros](https://blog.nimblepros.com/)

---

Every developer has written some version of this check: a user tries to register, and before you create the account, you need to know whether that email address is already taken. It's a simple question, but answering it means a trip to the database, and that's always been awkward for the built-in validation in ASP.NET Core. DataAnnotations validation was synchronous, so a database check either blocked a thread or got pushed somewhere else entirely.

ASP.NET Core 11 changes that. DataAnnotations now has async validation APIs, and both minimal APIs and Blazor forms run them. In this post, we'll look at how they work, a couple of gotchas that will bite you if you aren't careful, and the bigger question of whether a database check belongs in validation at all. We'll finish by comparing the new built-in approach with how FastEndpoints handles the same problem.

## The Two New APIs

There are two new pieces in `System.ComponentModel.DataAnnotations`, one for property-level rules and one for object-level rules.

### `AsyncValidationAttribute`

For a rule that applies to a single property, derive from `AsyncValidationAttribute` and override `IsValidAsync`:

```csharp
public sealed class UniqueEmailAttribute : AsyncValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext context) =>
        throw new InvalidOperationException("Validate this attribute with IsValidAsync.");

    protected override async Task<ValidationResult?> IsValidAsync(
        object? value, ValidationContext context, CancellationToken cancellationToken)
    {
        var users = context.GetRequiredService<IUserService>();

        if (value is string email && await users.EmailExistsAsync(email, cancellationToken))
        {
            return new ValidationResult("That email is already registered.");
        }

        return ValidationResult.Success;
    }
}
```

Then apply it like any other validation attribute:

```csharp
public class RegisterUserRequest
{
    [Required, EmailAddress, UniqueEmail]
    public string Email { get; set; } = "";

    [Required, StringLength(100)]
    public string DisplayName { get; set; } = "";
}
```

A few things to notice. You get a `CancellationToken`, so if the client disconnects, your database query can be canceled with it. And you get at your services through the `ValidationContext` with `GetRequiredService<T>()`, which resolves from the current request's scope.

### `IAsyncValidatableObject`

For rules that span multiple properties, implement `IAsyncValidatableObject` and return your results as an `IAsyncEnumerable<ValidationResult>`:

```csharp
public class ReservationRequest : IAsyncValidatableObject
{
    [Required, EmailAddress]
    public string Email { get; set; } = "";

    public DateOnly Date { get; set; }

    [Range(1, 12)]
    public int Guests { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext context) =>
        throw new InvalidOperationException("Validate this type with ValidateAsync.");

    public async IAsyncEnumerable<ValidationResult> ValidateAsync(
        ValidationContext context,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var rooms = context.GetRequiredService<IRoomService>();

        if (!await rooms.HasCapacityAsync(Date, Guests, cancellationToken))
        {
            yield return new ValidationResult(
                "There isn't enough availability on that date.", [nameof(Date), nameof(Guests)]);
        }
    }
}
```

## Wiring It Up

### Minimal APIs

If you're already using the built-in minimal API validation that arrived in .NET 10, there's nothing new to configure. Register validation, and async rules run automatically before your endpoint executes:

```csharp
builder.Services.AddValidation();

app.MapPost("/users", async (RegisterUserRequest request, IUserService users) =>
{
    var user = await users.RegisterAsync(request);
    return TypedResults.Created($"/users/{user.Id}", user);
});
```

If validation fails, the endpoint never runs, and the client gets a `400 Bad Request` with a standard validation problem details response.

### Blazor

Blazor's `EditForm` now awaits async validators on submit, in every render mode. The built-in `DataAnnotationsValidator` component runs `AsyncValidationAttribute` and `IAsyncValidatableObject` rules without any extra configuration, so the same attributes you use on your API requests work in your forms.

There are also new APIs for building more interactive experiences. `EditContext.RegisterAsyncFieldValidator` registers an async check for a single field, and `IsValidationPending` and `IsValidationFaulted` let you show the user what's happening while that check runs. The framework owns the cancellation token, and it cancels any check that's been superseded by newer input.

Let's put both pieces together in a registration form. The email address uses our `[UniqueEmail]` attribute, which runs when the form is submitted. The username gets a live availability check as soon as the user changes the field.

Here's the model:

```csharp
public class RegistrationModel
{
    [Required, EmailAddress, UniqueEmail]
    public string Email { get; set; } = "";

    [Required, StringLength(30, MinimumLength = 3)]
    public string Username { get; set; } = "";
}
```

And here's the component:

```html
@page "/register"
@rendermode InteractiveServer
@inject IUserService Users
@inject NavigationManager Navigation

<h1>Create an Account</h1>

<EditForm EditContext="editContext" OnValidSubmit="HandleValidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <div class="form-field">
        <label for="email">Email</label>
        <InputText id="email" @bind-Value="model.Email" />
        <ValidationMessage For="() => model.Email" />
    </div>

    <div class="form-field">
        <label for="username">Username</label>
        <InputText id="username" @bind-Value="model.Username" />

        @if (editContext.IsValidationPending(() => model.Username))
        {
            <span class="form-hint">Checking availability...</span>
        }
        else if (editContext.IsValidationFaulted(() => model.Username))
        {
            <span class="form-hint">We couldn't check that username. Please try again.</span>
        }

        <ValidationMessage For="() => model.Username" />
    </div>

    <button type="submit" disabled="@isSubmitting">Register</button>
</EditForm>
```

```csharp
@code {
    private readonly RegistrationModel model = new();
    private EditContext editContext = default!;
    private ValidationMessageStore usernameMessages = default!;
    private bool isSubmitting;

    protected override void OnInitialized()
    {
        editContext = new EditContext(model);
        usernameMessages = new ValidationMessageStore(editContext);
        editContext.OnFieldChanged += HandleFieldChanged;
    }

    private void HandleFieldChanged(object? sender, FieldChangedEventArgs e)
    {
        if (e.FieldIdentifier.FieldName != nameof(model.Username))
        {
            return;
        }

        editContext.RegisterAsyncFieldValidator(
            e.FieldIdentifier,
            token => CheckUsernameAsync(e.FieldIdentifier, model.Username, token));
    }

    private async Task CheckUsernameAsync(
        FieldIdentifier field, string username, CancellationToken cancellationToken)
    {
        usernameMessages.Clear(field);

        if (!string.IsNullOrWhiteSpace(username)
            && await Users.IsUsernameTakenAsync(username, cancellationToken))
        {
            usernameMessages.Add(field, "That username is already taken.");
        }

        editContext.NotifyValidationStateChanged();
    }

    private async Task HandleValidSubmit()
    {
        isSubmitting = true;

        await Users.RegisterAsync(model);
        Navigation.NavigateTo("/welcome");
    }
}
```

Let's walk through what's happening.

**The email check runs on submit.** We didn't write any code for it. `DataAnnotationsValidator` sees the `[UniqueEmail]` attribute and runs its `IsValidAsync` method when the user submits, and `EditForm` waits for it to finish before deciding whether to call `OnValidSubmit`. That's the same attribute class we used for the minimal API endpoint, with no changes.

**The username check runs as the user goes.** We create our own `EditContext` instead of passing the model straight to `EditForm`, so we can hook into `OnFieldChanged`. Whenever the username changes, we register an async validator for that field with `RegisterAsyncFieldValidator`. The validator writes its result into its own `ValidationMessageStore`, then calls `NotifyValidationStateChanged()` so the `ValidationMessage` component updates.

**The framework handles the messy parts.** If the user changes the username again while a check is still running, the framework cancels the earlier check through the `CancellationToken`, so a slow response for an old value can't overwrite the result for the new one. While a check is running, `IsValidationPending` returns `true` and we show "Checking availability...". If the check throws, perhaps because the database is unavailable, `IsValidationFaulted` returns `true` and we can show a friendly message instead of a blank stare.

**A taken username blocks submission.** Because the "already taken" message lives in a `ValidationMessageStore` attached to the form's `EditContext`, the form is invalid as long as that message is there, and `OnValidSubmit` won't run.

A few notes on this example:

- **`OnFieldChanged` fires when the field's value changes**, which for `InputText` is when the user leaves the field, not on every keystroke. That's usually what you want for a database check. Firing a query on every keypress gets expensive fast.
- **The live check needs an interactive render mode.** In static server-side rendering, there's no live connection to run the check as the user types, so you get submit-time validation only. That's still a good experience, especially since static SSR forms in .NET 11 can now run the simple rules, like `[Required]` and `[StringLength]`, in the browser without a round trip.
- **Watch your `DbContext` here, too.** In Blazor Server, scoped services live for the whole circuit, and the username check and the submit-time email check could run against the same `DbContext` at the same time. Microsoft already recommends `IDbContextFactory` for Blazor Server apps, and that advice applies doubly to async validators. We'll come back to this in Gotcha #2.
- **The live check is a convenience, not a guarantee.** It tells the user early that a username is taken. It doesn't stop two people from grabbing the same username at the same moment. More on that below.

## Gotcha #1: The Synchronous Method You Have to Throw From

You probably noticed something odd in the examples at the top. `AsyncValidationAttribute` still requires you to implement the synchronous `IsValid` method. And because `IAsyncValidatableObject` extends `IValidatableObject`, you have to implement the synchronous `Validate` method too.

The official guidance is to **throw** from the synchronous version when your rule is async-only. That might feel wrong to you, but let's look at the alternative. If you returned `ValidationResult.Success` from the synchronous method, any code that validates your model synchronously, such as an older library or a call to `Validator.TryValidateObject`, would silently skip your rule and report the model as valid. An exception is loud. A silently skipped uniqueness check is not.

So throw, and make the message clear about what happened. If you ever see that exception in your logs, it means something is validating your model synchronously, and you'll want to know about that.

## Gotcha #2: Validators Run Concurrently

This is the one I think will catch the most people. To keep validation fast, the framework runs async validators **concurrently** where it can. Async attributes on the same property start together, and items in a collection are validated in parallel.

That's great for performance, until you remember that `DbContext` isn't thread-safe.

Consider a bulk invite endpoint:

```csharp
public class BulkInviteRequest
{
    [Required, MinLength(1)]
    public List<InviteRequest> Invites { get; set; } = [];
}

public class InviteRequest
{
    [Required, EmailAddress, UniqueEmail]
    public string Email { get; set; } = "";
}
```

Now suppose `UniqueEmailAttribute` resolves your `AppDbContext` directly:

```csharp
var db = context.GetRequiredService<AppDbContext>();
var exists = await db.Users.AnyAsync(u => u.Email == email, cancellationToken);
```

Every invite in the list resolves the *same* scoped `AppDbContext`, because they all share the request's scope. If they run at the same time, they query that one context concurrently, and EF Core will throw its familiar `InvalidOperationException`: "A second operation was started on this context instance before a previous operation completed." It might work fine in testing with one invite, then fail in production the first time someone submits five.

There are a few ways to handle it:

**Use a `DbContext` factory.** Register `AddDbContextFactory<AppDbContext>()` and create a short-lived context inside the validator, so each check gets its own:

```csharp
var factory = context.GetRequiredService<IDbContextFactory<AppDbContext>>();
await using var db = await factory.CreateDbContextAsync(cancellationToken);

var exists = await db.Users.AnyAsync(u => u.Email == email, cancellationToken);
```

**Validate the collection as a whole.** Even with a factory, one query per item is an N+1 problem waiting to happen. For collections, it's usually better to move the check up to the parent with `IAsyncValidatableObject` and make a single query:

```csharp
public class BulkInviteRequest : IAsyncValidatableObject
{
    [Required, MinLength(1)]
    public List<InviteRequest> Invites { get; set; } = [];

    public IEnumerable<ValidationResult> Validate(ValidationContext context) =>
        throw new InvalidOperationException("Validate this type with ValidateAsync.");

    public async IAsyncEnumerable<ValidationResult> ValidateAsync(
        ValidationContext context,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var users = context.GetRequiredService<IUserService>();
        var emails = Invites.Select(i => i.Email).ToList();

        var alreadyRegistered = await users.FindRegisteredEmailsAsync(emails, cancellationToken);

        foreach (var email in alreadyRegistered)
        {
            yield return new ValidationResult(
                $"{email} is already registered.", [nameof(Invites)]);
        }
    }
}
```

**Or move the check out of validation entirely.** Which brings us to the bigger question.

## Validation Isn't Enough

Here's the uncomfortable truth about `[UniqueEmail]`: it doesn't actually guarantee unique emails.

Two requests come in at nearly the same moment with the same email address. Both run validation. Both check the database. Neither finds an existing user, because neither has saved yet. Both pass validation. Both insert. Now you have two accounts with the same email.

This is a classic check-then-act race condition, and no amount of validation can close it. The only thing that can truly guarantee uniqueness is the database itself:

```csharp
public class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.HasIndex(u => u.Email).IsUnique();
    }
}
```

With a unique index in place, the second insert fails with a `DbUpdateException`, which you can catch and translate into a `409 Conflict` or a friendly error message. Exactly how you detect a unique constraint violation depends on your database provider, so wrap that detection up in one place in your infrastructure layer.

So is the async validator pointless? Not at all. It catches the duplicate in the 99.9% case and gives the user a clear, specific error before you do any other work. The unique index is the safety net for the rare race. You want both.

But this raises a design question that's worth thinking about, especially if you follow Clean Architecture or Domain-Driven Design. There's a difference between **input validation** and **business rules**:

- **Input validation** asks: is this request well-formed? Is the email field present and shaped like an email? Is the guest count between 1 and 12? These checks need nothing but the request itself.
- **Business rules** ask: is this operation allowed, given the current state of the system? Is this email already registered? Is there room on that date? These checks need data.

Async validation makes it *easy* to put business rules into your request validation. Just because you can doesn't always mean you should. A uniqueness check in an attribute on your request DTO means a business rule now lives in your presentation layer, tied to one particular endpoint. If the same rule needs to apply when a user is created by an import job or a message handler, you either duplicate it or it gets skipped.

What do I think? Keep request validation focused on whether the request is well-formed, and let your use case or domain layer enforce the business rules, backed by database constraints where it counts. Async validation is a good fit for checks that really are about the request itself, or for giving users fast feedback in a UI, like a Blazor form checking username availability as they type. For anything that's a true invariant of your system, put it where the rest of your business rules live.

## How FastEndpoints Compares

But what if you use FastEndpoints instead of minimal APIs? We've compared FastEndpoints with minimal APIs and controllers [in a previous post](/blogs/fastendpoints-controllers-and-minimal-apis-compared/) our NimblePros blog. So how does it handle this same problem?

**FastEndpoints has had async validation for a long time.** It uses FluentValidation under the hood, and FluentValidation has always supported async rules with `MustAsync`:

```csharp
public class RegisterUserValidator : Validator<RegisterUserRequest>
{
    public RegisterUserValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(async (email, ct) =>
            {
                var users = Resolve<IUserService>();
                return !await users.EmailExistsAsync(email, ct);
            })
            .WithMessage("That email is already registered.");
    }
}
```

**Validators are singletons.** For performance reasons, FastEndpoints creates each validator once. That means you can't inject a scoped service like a `DbContext` through the validator's constructor. Inside a rule, you call `Resolve<T>()`, which resolves from the current request's scope, as shown above. It's the same idea as `context.GetRequiredService<T>()` in the built-in approach, just with a different API.

**Business rules have a natural home in the handler.** FastEndpoints gives you `AddError`, `ThrowIfAnyErrors`, and `ThrowError` right in the endpoint, which makes it easy to follow the split I described above. Keep the validator focused on the shape of the request, and enforce the data-dependent rules in the handler or deeper:

```csharp
public override async Task HandleAsync(RegisterUserRequest req, CancellationToken ct)
{
    if (await _users.EmailExistsAsync(req.Email, ct))
    {
        AddError(r => r.Email, "That email is already registered.");
    }

    ThrowIfAnyErrors();

    var user = await _users.RegisterAsync(req, ct);
    await Send.CreatedAtAsync<GetUserEndpoint>(new { user.Id }, user, cancellation: ct);
}
```

**The new built-in support doesn't apply to FastEndpoints.** The async DataAnnotations support in ASP.NET Core 11 is driven by the `Microsoft.Extensions.Validation` pipeline that powers minimal APIs, plus Blazor's `DataAnnotationsValidator`. FastEndpoints has its own validation pipeline. It does have optional DataAnnotations support, but don't assume your new `AsyncValidationAttribute` classes will run there. Test it before you rely on it.

Here's a quick side-by-side:

| | Minimal APIs (ASP.NET Core 11) | FastEndpoints |
|---|---|---|
| Validation library | DataAnnotations via `Microsoft.Extensions.Validation` | FluentValidation |
| Async property rules | `AsyncValidationAttribute` | `MustAsync` |
| Async object-level rules | `IAsyncValidatableObject` | Rules on the validator, or handler checks |
| Accessing services | `context.GetRequiredService<T>()` | `Resolve<T>()` inside rules |
| Rule location | Attributes on the request model | Separate validator class |
| Business-rule errors in the endpoint | Return a result yourself | `AddError`, `ThrowIfAnyErrors`, `ThrowError` |
| Shared with Blazor forms | Yes, same model and attributes | No |

So which should you use? If you're on minimal APIs, the built-in support is a welcome addition, and it means one less reason to pull in another library. The Blazor story is a genuine advantage too: the same model and attributes drive both your API validation and your form validation. If you're already on FastEndpoints, nothing here should make you switch. FluentValidation's separate validator classes keep your request models clean, and having the handler as the obvious place for business rules nudges you toward a good design.

Whichever you pick, the same advice applies: validate the shape of the request at the edge, enforce business rules in your use cases, and let the database have the final word on uniqueness.

## Wrapping Up

Async validation fills a real gap in ASP.NET Core's built-in validation, and the implementation is thoughtful: cancellation support, request-scoped services, concurrent execution, and the same rules working across minimal APIs and Blazor.

Just keep the gotchas in mind. Throw from the synchronous methods. Remember that validators can run concurrently, and don't share a `DbContext` across them. And don't mistake a validation check for a guarantee. A unique index is still your best friend.

## Resources

- [What's New in ASP.NET Core in .NET 11](https://learn.microsoft.com/aspnet/core/release-notes/aspnetcore-11)
- [Validation in ASP.NET Core](https://learn.microsoft.com/aspnet/core/fundamentals/validation)
- [Blazor Advanced Form Validation](https://learn.microsoft.com/aspnet/core/blazor/forms/validation-advanced)
- [FastEndpoints Validation](https://fast-endpoints.com/docs/validation)
- [FastEndpoints Dependency Injection](https://fast-endpoints.com/docs/dependency-injection)
