Start Debugging

Eine Minimal API von manuellen Validierungsprüfungen zur integrierten Validierung in ASP.NET Core 11 migrieren

Eine Schritt-für-Schritt-Anleitung, um handgeschriebene if-Prüfungen in den Handlern einer ASP.NET-Core-11-Minimal-API durch den integrierten, quellcodegenerierten DataAnnotations-Validator zu ersetzen: was bricht, welche manuellen Regeln portierbar sind und welche nicht, und wie Sie prüfen, dass der 400-ProblemDetails-Vertrag identisch bleibt.

Wenn Ihre ASP.NET-Core-Minimal-API älter als .NET 10 ist, beginnt vermutlich jeder Handler mit einem Block aus if (string.IsNullOrWhiteSpace(...)) return Results.BadRequest(...)-Prüfungen. Diese Migration ersetzt jene handgeschriebenen Prüfungen durch die integrierte Validierung, die in .NET 10 eingeführt wurde und in .NET 11 unverändert bleibt: Rufen Sie builder.Services.AddValidation() auf, verschieben Sie die Regeln als DataAnnotations-Attribute auf Ihre Request-Records und löschen Sie die manuellen Wächter. Für einen typischen Dienst mit 15 bis 40 Endpunkten rechnen Sie mit einem halben bis einem Tag mechanischer Arbeit. Was bricht, ist nicht das Framework, sondern Ihre Annahmen: Einige Ihrer manuellen Regeln taten Dinge, die Attribute nicht ausdrücken können (asynchrone Abfragen, dienstübergreifende Prüfungen), und die Form Ihrer Fehlerantwort kann sich verschieben, wenn Sie BadRequest-Payloads von Hand gebaut haben, statt ProblemDetails zurückzugeben. Die Migration lohnt sich, weil sie Code löscht, die Validierung deklarativ und wiederverwendbar macht und unter Native AOT trim-sicher bleibt. Sie ist außerdem Endpunkt für Endpunkt umkehrbar, sodass Sie sie inkrementell durchführen können. Alles Folgende zielt auf .NET 11 mit Microsoft.NET.Sdk.Web und C# 14; das Feature ist identisch in .NET 10, wo es zuerst erschien.

Warum von manuellen Prüfungen weggehen

Das manuelle Muster funktioniert, daher geht es beim Argument für eine Migration um Wartbarkeit und Korrektheit, nicht um “der alte Weg ist kaputt”:

Was bricht

Das Framework selbst bricht nicht, aber diese fünf Dinge ändern sich, und Sie müssen wissen, welche Ihrer manuellen Regeln die Portierung überleben:

BereichÄnderungSchweregrad
Fehlerantwort-BodyReine Zeichenketten- oder benutzerdefinierte BadRequest(...)-Bodys werden zu ProblemDetails (RFC 9457) mit Eigenschaft als Schlüsselhoch
Asynchrone / DB-Regelnif (await repo.SkuExists(...)) kann kein Attribut werden; braucht ein anderes Zuhausehoch
Zugreifbarkeit des Request-TypsDer Record muss public sein, sonst gibt der Generator nichts aus und die Validierung läuft still nichthoch
Feldübergreifende Regelnif-Blöcke über mehrere Eigenschaften wandern zu IValidatableObject, nicht zu Attributenmittel
Tests, die auf Fehlertext prüfenTests, die auf Ihre alten benutzerdefinierten Nachrichten-Strings prüfen, scheitern am neuen ProblemDetailsmittel
Rückgabetyp des HandlersHandler können ihren BadRequest-Zweig entfernen; der Filter erzeugt den 400, bevor der Handler läuftniedrig

Die beiden Zeilen mit hohem Schweregrad, die die Form Ihrer Migration bestimmen, sind der Fehler-Body und die asynchronen Regeln. Wenn Clients Ihren aktuellen 400-Body parsen, planen Sie eine Vertragsänderung oder eine Kompatibilitätsschicht. Wenn irgendeine manuelle Prüfung auf E/A wartet, wird diese Regel überhaupt kein Attribut, und das Gegenteil anzunehmen ist die häufigste Art, wie diese Migration schiefgeht.

Vorabprüfliste

Bevor Sie einen Handler anfassen:

Migrationsschritte

1. Integrierte Validierung einschalten

Registrieren Sie die Dienste, bevor Sie die App bauen. Dies ist die einzige globale Verdrahtung, die die Migration benötigt.

// .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();

Mit dem aktuellen .NET-10- oder -11-Web-SDK ist der Source Generator automatisch aktiv, sobald AddValidation() vorhanden ist. Wenn Sie ein getrimmtes .csproj kopiert haben oder Ihr Build vor der GA liegt, fügen Sie den Interceptor-Namespace explizit hinzu:

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

Prüfen: Die App baut und startet weiterhin. dotnet build meldet null Fehler. Es hat sich noch kein Verhalten geändert, weil keiner Ihrer Typen Attribute trägt.

2. Feldbezogene Regeln auf den Request-Record verschieben

Nehmen Sie einen Endpunkt. Lesen Sie seine manuellen Prüfungen und übersetzen Sie jede als Attribut ausdrückbare Regel in ein DataAnnotations-Attribut auf dem Request-Typ. Hier das Vorher, ein Handler, der seine Validierung inline trägt:

// .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);

Und das Nachher: Die Regeln wandern als Attribute auf den Record, der Handler verliert seinen Wächterblock, und der Typ wird public, damit der Generator ihn sehen kann.

// .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);

Beachten Sie, dass der Typ public wurde. Dies ist der häufigste Grund, warum ein migrierter Endpunkt still aufhört zu validieren: Der Generator kann nur Code für Typen ausgeben, die er benennen kann, sodass ein record, der in der standardmäßigen dateiinternen Zugreifbarkeit belassen wird, keine Validierung und keinen Fehler erhält.

Prüfen: curl -i -X POST .../products -d '{"sku":"x","name":"","quantity":0}' gibt 400 mit einem ProblemDetails-Body zurück, der Sku, Name und Quantity auflistet. Ein gültiger Body gibt weiterhin 201 zurück.

3. Feldübergreifende Regeln zu IValidatableObject verschieben

Attribute validieren jeweils ein Mitglied. Jede manuelle Prüfung, die zwei Felder verglich (“Ende nach Anfang”, “Rabatt erfordert einen Grund”), wird kein Attribut. Implementieren Sie stattdessen IValidatableObject auf dem Request-Record:

// .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
        }
    }
}

Das Array von Mitgliedsnamen, das Sie als zweites Argument übergeben, steuert, unter welchem Schlüssel der Fehler landet, übergeben Sie also das Feld, das Ihre UI hervorheben soll. Validate läuft nach den Attributprüfungen, sodass ein null-Start bereits von [Required] gemeldet wird, bevor Ihre feldübergreifende Logik läuft.

Prüfen: Posten Sie einen Bereich, in dem End <= Start, und bestätigen Sie, dass der Fehlerschlüssel End ist, nicht ein leerer Schlüssel auf Modellebene.

4. Die asynchronen und dienstübergreifenden Prüfungen umsiedeln

Dies ist der Schritt, in den das Migrations-Backlog aus Ihrem vorangegangenen await-grep einfließt. Eine Regel wie “ablehnen, wenn das SKU bereits im Katalog existiert” trifft die Datenbank, und weder DataAnnotations noch IValidatableObject können warten. Sie haben zwei ehrliche Optionen:

Behalten Sie diese spezifische Prüfung als expliziten Aufruf innerhalb des Handlers, nachdem der integrierte Validator die Form bereits als gültig garantiert hat:

// .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);
});

Oder, wenn Sie viele asynchrone Regeln haben, behalten Sie FluentValidation genau für diese Request-Typen und lassen Sie das integrierte Feature die einfachen übernehmen. Die Abwägungen sind in Minimal-API-Validierung vs FluentValidation dargelegt; die Kurzfassung ist, dass asynchrone Regeln und reiche bedingte Logik die zwei Gründe sind, eine Validierungsbibliothek zu behalten. Versuchen Sie nicht, einen DbContext-Aufruf in ein IValidatableObject zu zwingen; es ist synchron und wird entweder einen Thread blockieren oder Sie in das Deadlock-Gebiet mit .Result treiben.

Prüfen: Die Eindeutigkeitsprüfung lehnt weiterhin ein doppeltes SKU ab (jetzt als 409 oder welchen Status Sie wählen), und ein erstmaliges SKU ist weiterhin erfolgreich.

5. Eine Regel mit einem benutzerdefinierten ValidationAttribute wiederverwenden

Wenn dieselbe manuelle Prüfung in drei Handlern auftauchte (“dieses Datum darf nicht in der Vergangenheit liegen”), kopieren Sie kein IValidatableObject in drei Records. Schreiben Sie ein ValidationAttribute, und der Generator greift es auf wie jedes eingebaute:

// .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);

Dies ist die attributbasierte Antwort auf duplizierte if-Blöcke: Die Regel lebt einmal und gilt überall dort, wo der Typ verwendet wird.

Prüfen: Ein Endpunkt, der zuvor dieselbe Datumsprüfung wiederholte, lehnt nun ein vergangenes Datum mit dem Attribut ab, und die anderen Endpunkte, die sich den Typ teilen, erben die Regel.

6. Toten Wächtercode löschen und Rückgabetypen vereinfachen

Sobald die Regeln eines Endpunkts deklarativ sind, entfernen Sie den manuellen if-Block vollständig und vereinfachen Sie den deklarierten Rückgabetyp des Handlers. Da der 400 vom Endpunkt-Filter erzeugt wird, bevor der Handler-Body läuft, braucht ein Handler keinen BadRequest-Zweig mehr in seiner 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

Prüfen: OpenAPI bewirbt weiterhin den 400 (über ProducesValidationProblem), und der Endpunkt kompiliert ohne den entfernten Union-Zweig.

Verifikation

Nachdem Sie eine Charge von Endpunkten migriert haben, gehen Sie diese Liste durch, bevor Sie es für erledigt erklären:

Rollback-Plan

Diese Migration ist umkehrbar und, besser noch, inkrementell, sodass Sie selten einen vollständigen Rollback brauchen. Zwei Ebenen:

Stolpersteine, die uns begegnet sind

Reale Probleme, die Zeit gekostet haben, und ihre Lösungen:

Das mentale Modell zum Mitnehmen: Diese Migration ist nicht die Übernahme eines neuen Frameworks, sie ist das Löschen imperativen Wächtercodes und das erneute Ausdrücken derselben Regeln deklarativ auf Ihren Request-Typen. Die als Attribut ausdrückbaren Regeln wandern zu DataAnnotations, die feldübergreifenden zu IValidatableObject, die wiederverwendbaren zu einem benutzerdefinierten ValidationAttribute, und die asynchronen bleiben explizit im Handler, nachdem die Form bereits als gültig garantiert ist. Machen Sie es Endpunkt für Endpunkt, prüfen Sie jeden mit dem Aufruf der ungültigen Anfrage, und der if-Block-Boilerplate, der jeden Handler eröffnete, verschwindet einfach.

Verwandtes

Quellen

Comments

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

< Zurück