Start Debugging

Migrate a minimal API from manual validation checks to built-in validation in ASP.NET Core 11

A step-by-step migration guide for replacing hand-rolled if-checks in ASP.NET Core 11 minimal API handlers with the built-in source-generated DataAnnotations validator: what breaks, which manual rules do and do not port, and how to verify the 400 ProblemDetails contract stays identical.

If your ASP.NET Core minimal API predates .NET 10, every handler probably opens with a block of if (string.IsNullOrWhiteSpace(...)) return Results.BadRequest(...) checks. This migration replaces those hand-rolled checks with the built-in validation that shipped in .NET 10 and is unchanged in .NET 11: call builder.Services.AddValidation(), move the rules onto your request records as DataAnnotations attributes, and delete the manual guards. For a typical service of 15 to 40 endpoints, expect half a day to a day of mechanical work. What breaks is not the framework, it is your assumptions: a handful of your manual rules were doing things attributes cannot express (async lookups, cross-service checks), and your error-response shape may shift if you were hand-building BadRequest payloads instead of returning ProblemDetails. The migration is worth it because it deletes code, makes validation declarative and reusable, and stays trim-safe under Native AOT. It is also reversible endpoint by endpoint, so you can do it incrementally. Everything below targets .NET 11 with Microsoft.NET.Sdk.Web and C# 14; the feature is identical on .NET 10, where it first shipped.

Why move off manual checks

The manual pattern works, so the case for migrating is about maintenance and correctness, not “the old way is broken”:

What breaks

The framework itself does not break, but these five things change, and you need to know which of your manual rules survive the port:

AreaChangeSeverity
Error response bodyBare-string or custom BadRequest(...) bodies become RFC 9457 ProblemDetails keyed by propertyhigh
Async / DB rulesif (await repo.SkuExists(...)) cannot become an attribute; needs a different homehigh
Request type accessibilityThe record must be public or the generator emits nothing and validation silently does not runhigh
Cross-field rulesMulti-property if blocks move to IValidatableObject, not attributesmedium
Tests asserting on error textTests that assert on your old custom message strings will fail against the new ProblemDetailsmedium
Handler return typeHandlers can drop their BadRequest union arm; the filter produces the 400 before the handler runslow

The two high-severity rows that decide the shape of your migration are the error body and the async rules. If clients parse your current 400 body, plan for a contract change or a compatibility shim. If any manual check awaits I/O, that rule does not become an attribute at all, and pretending otherwise is the most common way this migration goes wrong.

Pre-flight checklist

Before you touch a handler:

Migration steps

1. Turn on built-in validation

Register the services before you build the app. This is the only global wiring the migration needs.

// .NET 11, C# 14 -- Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();   // registers the validation services and endpoint filter
var app = builder.Build();

With the current .NET 10 or 11 web SDK the source generator is active automatically once AddValidation() is present. If you copied a trimmed .csproj or your build predates GA, add the interceptor namespace explicitly:

<!-- only needed if the generator's interceptors are not picked up automatically -->
<PropertyGroup>
  <InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces>
</PropertyGroup>

Verify: the app still builds and starts. dotnet build reports zero errors. No behavior has changed yet because none of your types carry attributes.

2. Move field-level rules onto the request record

Take one endpoint. Read its manual checks and translate each attribute-expressible rule to a DataAnnotations attribute on the request type. Here is the before, a handler carrying its validation inline:

// .NET 11, C# 14 -- BEFORE: manual checks
app.MapPost("/products", (CreateProduct product) =>
{
    if (string.IsNullOrWhiteSpace(product.Sku) || product.Sku.Length is < 3 or > 20)
        return Results.BadRequest("Sku must be 3 to 20 characters.");
    if (string.IsNullOrWhiteSpace(product.Name) || product.Name.Length < 2)
        return Results.BadRequest("Name is required and must be at least 2 characters.");
    if (product.Quantity is < 1 or > 10_000)
        return Results.BadRequest("Quantity must be between 1 and 10000.");

    return Results.Created($"/products/{product.Sku}", product);
});

public record CreateProduct(string Sku, string Name, int Quantity);

And the after: the rules move to the record as attributes, the handler loses its guard block, and the type is made public so the generator can see it.

// .NET 11, C# 14 -- AFTER: declarative validation
using System.ComponentModel.DataAnnotations;

app.MapPost("/products", (CreateProduct product) =>
    TypedResults.Created($"/products/{product.Sku}", product));

public record CreateProduct(
    [Required, Length(3, 20)] string Sku,
    [Required, MinLength(2)] string Name,
    [Range(1, 10_000)] int Quantity);

Note the type became public. This is the single most common reason a migrated endpoint silently stops validating: the generator can only emit code for types it can name, so a record left at the default file-internal accessibility gets no validation and no error.

Verify: curl -i -X POST .../products -d '{"sku":"x","name":"","quantity":0}' returns 400 with a ProblemDetails body listing Sku, Name, and Quantity. A valid body still returns 201.

3. Move cross-field rules to IValidatableObject

Attributes validate one member at a time. Any manual check that compared two fields (“end after start”, “discount requires a reason”) does not become an attribute. Implement IValidatableObject on the request record instead:

// .NET 11, C# 14 -- cross-field rule ported from a manual if-block
using System.ComponentModel.DataAnnotations;

public record DateRange(
    [Required] DateOnly Start,
    [Required] DateOnly End) : IValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (End <= Start)
        {
            yield return new ValidationResult(
                "End must be after Start.",
                [nameof(End)]);   // attaches the error to the End member
        }
    }
}

The member-name array you pass as the second argument controls which key the error lands under, so pass the field your UI should highlight. Validate runs after the attribute checks, so a null Start is already reported by [Required] before your cross-field logic runs.

Verify: post a range where End <= Start and confirm the error key is End, not an empty model-level key.

4. Rehome the async and cross-service checks

This is the step the migration backlog from your pre-flight await grep feeds into. A rule like “reject if the SKU already exists in the catalog” hits the database, and neither DataAnnotations nor IValidatableObject can await. You have two honest options:

Keep that specific check as an explicit call inside the handler, after the built-in validator has already guaranteed the shape is valid:

// .NET 11, C# 14 -- async rule stays explicit, but shape is pre-validated
app.MapPost("/products", async (CreateProduct product, IProductRepository repo, CancellationToken ct) =>
{
    // Built-in validation already ran: Sku is non-null, 3-20 chars, Quantity in range.
    if (await repo.SkuExistsAsync(product.Sku, ct))
        return Results.Conflict($"A product with SKU {product.Sku} already exists.");

    await repo.AddAsync(product, ct);
    return TypedResults.Created($"/products/{product.Sku}", product);
});

Or, if you have many async rules, keep FluentValidation for exactly those request types and let the built-in feature own the simple ones. The trade-offs are laid out in minimal API validation vs FluentValidation; the short version is that async and rich conditional logic are the two reasons to keep a validation library. Do not try to force a DbContext call into an IValidatableObject; it is synchronous and will either block a thread or push you into .Result deadlock territory.

Verify: the uniqueness check still rejects a duplicate SKU (now as a 409, or whatever status you choose), and a first-time SKU still succeeds.

5. Reuse a rule with a custom ValidationAttribute

If the same manual check appeared in three handlers (“this date cannot be in the past”), do not copy an IValidatableObject into three records. Write one ValidationAttribute and the generator picks it up like any built-in:

// .NET 11, C# 14
using System.ComponentModel.DataAnnotations;

public sealed class NotInPastAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext context)
    {
        if (value is DateOnly date && date < DateOnly.FromDateTime(DateTime.UtcNow.Date))
        {
            return new ValidationResult(
                ErrorMessage ?? "Date cannot be in the past.",
                [context.MemberName!]);
        }
        return ValidationResult.Success;
    }
}

public record Booking([Required, NotInPast] DateOnly When);

This is the attribute-based answer to duplicated if blocks: the rule lives once and applies wherever the type is used.

Verify: an endpoint that previously repeated the same date check now rejects a past date with the attribute, and the other endpoints that share the type inherit the rule.

6. Delete the dead guard code and simplify return types

Once an endpoint’s rules are declarative, remove the manual if block entirely, and simplify the handler’s declared return type. Because the 400 is produced by the endpoint filter before the handler body runs, a handler no longer needs a BadRequest arm in its Results<...> union:

// .NET 11, C# 14 -- return type no longer needs BadRequest
app.MapPost("/products", (CreateProduct product)
    => TypedResults.Created($"/products/{product.Sku}", product))
   .Produces<CreateProduct>(StatusCodes.Status201Created)
   .ProducesValidationProblem();   // documents the 400 the filter produces

Verify: OpenAPI still advertises the 400 (via ProducesValidationProblem), and the endpoint compiles without the removed union arm.

Verification

After migrating a batch of endpoints, run through this checklist before you call it done:

Rollback plan

This migration is reversible and, better, it is incremental, so you rarely need a full rollback. Two levels:

Gotchas we hit

Real issues that cost time, and their fixes:

The mental model to leave with: this migration is not adopting a new framework, it is deleting imperative guard code and re-expressing the same rules declaratively on your request types. The attribute-expressible rules move to DataAnnotations, the cross-field ones to IValidatableObject, the reusable ones to a custom ValidationAttribute, and the async ones stay explicit in the handler after the shape is already guaranteed valid. Do it one endpoint at a time, verify each with the invalid-request call, and the if-block boilerplate that opened every handler simply goes away.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back