Friday, July 17, 2026

ASP.NET Core 11.0: Async Validation in Minimal APIs

In this post, let's have a look at asynchronous validation support for Minimal APIs, which is going to be available in ASP.NET Core 11.0.

Some time back, I blogged about Validation Support for Minimal APIs and Custom Validation in ASP.NET Core 10.0. This is a continuation of that, but this time the validators run asynchronously.

With the latest .NET 11 Preview 6, Minimal APIs now supports asynchronous validators end-to-end.

Let's look at the two ways to write asynchronous validators.

AsyncValidationAttribute

This is the asynchronous version of a custom ValidationAttribute. We inherit from AsyncValidationAttribute and override IsValidAsync. Here we can resolve the services from the ValidationContext and use those services for our validation logic.
public record Product(
    [Required, UniqueSku] string Sku,
    [Required] string Name);

internal sealed class UniqueSkuAttribute : AsyncValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) =>
        throw new InvalidOperationException($"Validate this attribute with '{nameof(IsValidAsync)}'.");

    protected override async Task<ValidationResult?> IsValidAsync(object? value,
        ValidationContext validationContext,
        CancellationToken cancellationToken)
    {
        ICatalogService catalogService = validationContext.GetRequiredService<ICatalogService>();
        if (value is string sku && await catalogService.SkuExistsAsync(sku, cancellationToken))
        {
            return new ValidationResult("A product with that SKU already exists.");
        }

        return ValidationResult.Success;
    }
}
Note the UniqueSku attribute in Sku. That's the custom attribute we created that performs an asynchronous validation. Here since IsValid in ValidationAttribute is abstract, we have to implement it even though we validate asynchronously. Here it just throws. 

Something to remember: for minimal API validation using Microsoft.Extensions.Validation, the framework always calls the async path and never the sync path. So it's safe to throw.

Now say we have the following API.
using System.ComponentModel.DataAnnotations;
using System.Runtime.CompilerServices;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// Add validation services to the container
builder.Services.AddValidation();

builder.Services.AddSingleton<ICatalogService, DummyCatalogService>();

WebApplication app = builder.Build();

app.MapPost("/products", (Product product) =>
    Results.Ok(product));

app.MapPost("/orders", (OrderRequest orderRequest) =>
    Results.Ok(orderRequest));

app.Run();
Just like before, we enable validation by calling AddValidation()

Now let's create a Product with an existing SKU.
@WebApplication1_HostAddress = http://localhost:5016

POST {{WebApplication1_HostAddress}}/products
Content-Type: application/json

{
  "sku": "EXISTING_SKU",
  "name": "Some Product"
}
Throws 400 as expected.
400: Bad Request
IAsyncValidatableObject

This is the asynchronous version of IValidatableObject. This is useful when the validation needs more than one property. We implement ValidateAsync, which returns an IAsyncEnumerable<ValidationResult>.
public record OrderRequest(
    [Range(1, int.MaxValue)] int ProductId,
    [Range(1, int.MaxValue)] int Quantity,
    string PromoCode) : IAsyncValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) =>
        throw new InvalidOperationException($"Validate this type with '{nameof(ValidateAsync)}'.");

    public async IAsyncEnumerable<ValidationResult> ValidateAsync(
        ValidationContext validationContext,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ICatalogService catalogService = validationContext.GetRequiredService<ICatalogService>();

        if (!await catalogService.HasStockAsync(ProductId, Quantity, cancellationToken))
        {
            yield return new ValidationResult(
                $"Product '{ProductId}' does not have '{Quantity}' unit(s) in stock.",
                [nameof(Quantity)]);
        }

        if (!await catalogService.IsPromoCodeValidAsync(PromoCode, cancellationToken))
        {
            yield return new ValidationResult(
                $"Promo code '{PromoCode}' is not valid.",
                [nameof(PromoCode)]);
        }
    }
}
IAsyncValidatableObject extends IValidatableObject, so the synchronous Validate still has to be there. Same as before, it just throws.

Ordering more than what we have in stock and also with an invalid Promo Code,
@WebApplication1_HostAddress = http://localhost:5016

POST {{WebApplication1_HostAddress}}/orders
Content-Type: application/json

{
  "productId": 1,
  "quantity": 100,
  "promoCode": "INVALID"
}
Throws 400 as expected.
400: Bad Request
Hope this helps.

Happy Coding.

Regards,
Jaliya

No comments:

Post a Comment