Start Debugging

ASP.NET Core 11 で minimal API を手動の検証チェックから組み込み検証へ移行する

ASP.NET Core 11 の minimal API ハンドラーにある手書きの if チェックを、DataAnnotations とソースジェネレーターに基づく組み込みバリデーターへ置き換えるためのステップバイステップの移行ガイドです。何が壊れるか、どの手動ルールが移植できてどれができないか、そして 400 ProblemDetails の契約が同一のままであることをどう検証するかを扱います。

ASP.NET Core の minimal API が .NET 10 より前に作られたものなら、おそらく各ハンドラーは if (string.IsNullOrWhiteSpace(...)) return Results.BadRequest(...) というチェックのブロックで始まっているはずです。この移行では、それらの手書きのチェックを、.NET 10 で登場し .NET 11 でも変わらない組み込み検証へ置き換えます。builder.Services.AddValidation() を呼び出し、ルールを DataAnnotations 属性としてリクエスト record へ移し、手動のガードを削除します。エンドポイントが 15 から 40 個ある典型的なサービスなら、半日から 1 日ほどの機械的な作業を見込んでください。壊れるのはフレームワークではなく、あなたの前提です。手動ルールの一部は属性では表現できないこと(非同期ルックアップ、サービス間チェック)を行っており、また ProblemDetails を返す代わりに BadRequest の payload を手で組み立てていた場合は、エラー応答の形が変わることがあります。この移行に価値があるのは、コードを削除でき、検証を宣言的で再利用可能にでき、Native AOT 下でも trim セーフを保てるからです。さらにエンドポイント単位で元に戻せるため、段階的に進められます。以下はすべて Microsoft.NET.Sdk.Web と C# 14 を用いた .NET 11 を対象とします。この機能は最初に登場した .NET 10 でも同一です。

なぜ手動チェックから離れるのか

手動のパターンは機能するので、移行の論拠は「古いやり方が壊れている」ではなく、保守性と正しさに関するものです。

何が壊れるか

フレームワーク自体は壊れませんが、次の 5 つは変わるため、手動ルールのどれが移植を生き延びるかを知る必要があります。

領域変更重大度
エラー応答ボディ素のstring や独自の BadRequest(...) ボディが、プロパティをキーとする ProblemDetails(RFC 9457)になる
非同期 / DB ルールif (await repo.SkuExists(...)) は属性にできない。別の置き場所が必要
リクエスト型のアクセシビリティrecord は public でなければならず、そうでないとジェネレーターは何も出力せず検証は静かに動かない
フィールド間ルール複数プロパティの if ブロックは属性ではなく IValidatableObject へ移る
エラーテキストを検証するテスト古い独自メッセージ文字列を検証するテストは新しい ProblemDetails に対して失敗する
ハンドラーの戻り値型ハンドラーは BadRequest の分岐を落とせる。フィルターがハンドラー実行前に 400 を生成する

移行の形を決める重大度「高」の 2 行は、エラーボディと非同期ルールです。クライアントが現在の 400 ボディをパースしているなら、契約の変更か互換シムを計画してください。手動チェックのいずれかが I/O を待つなら、そのルールは属性にはまったくならず、そうでないふりをすることが、この移行がうまくいかない最も一般的な原因です。

事前チェックリスト

ハンドラーに触れる前に。

移行の手順

1. 組み込み検証を有効にする

アプリをビルドする前にサービスを登録します。これは移行が必要とする唯一のグローバルな配線です。

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

現在の .NET 10 または 11 の web SDK では、AddValidation() が存在すればソースジェネレーターは自動的に有効になります。trim 済みの .csproj をコピーした場合や、ビルドが GA より前の場合は、インターセプターの名前空間を明示的に追加します。

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

検証: アプリは引き続きビルドされ起動します。dotnet build はエラー 0 を報告します。どの型もまだ属性を持たないため、動作はまだ何も変わっていません。

2. フィールド単位のルールをリクエスト record へ移す

エンドポイントを 1 つ取ります。その手動チェックを読み、属性で表現できる各ルールをリクエスト型上の DataAnnotations 属性へ翻訳します。次が変更前で、検証をインラインで抱えるハンドラーです。

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

そして変更後です。ルールは属性として record へ移り、ハンドラーはガードブロックを失い、ジェネレーターが見えるように型は public になります。

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

型が public になった点に注意してください。これは、移行したエンドポイントが静かに検証をやめる最も一般的な理由です。ジェネレーターは名前を付けられる型に対してのみコードを出力できるため、既定のファイル内部アクセシビリティのまま残された record は、検証もエラーも得られません。

検証: curl -i -X POST .../products -d '{"sku":"x","name":"","quantity":0}' は、SkuNameQuantity を列挙する ProblemDetails ボディ付きで 400 を返します。有効なボディは引き続き 201 を返します。

3. フィールド間ルールを IValidatableObject へ移す

属性は 1 度に 1 つのメンバーを検証します。2 つのフィールドを比較していた手動チェック(「終了は開始より後」「割引には理由が必要」)は属性になりません。代わりにリクエスト record 上で IValidatableObject を実装します。

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

第 2 引数として渡すメンバー名の配列が、エラーがどのキーに入るかを制御するので、UI で強調したいフィールドを渡します。Validate は属性チェックの後に実行されるため、nullStart はフィールド間ロジックが動く前にすでに [Required] によって報告されています。

検証: End <= Start となる範囲を POST し、エラーキーがモデルレベルの空キーではなく End であることを確認します。

4. 非同期・サービス間チェックを置き直す

これは、事前の await の grep から得た移行バックログが流れ込む手順です。「SKU がカタログにすでに存在するなら拒否する」のようなルールはデータベースにアクセスしますが、DataAnnotationsIValidatableObject も待機できません。誠実な選択肢は 2 つあります。

その特定のチェックを、組み込みバリデーターがすでに形の有効性を保証した後で、ハンドラー内の明示的な呼び出しとして残します。

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

あるいは、非同期ルールが多いなら、まさにそれらのリクエスト型のためだけに FluentValidation を残し、単純なものは組み込み機能に任せます。トレードオフは minimal API 検証と FluentValidation の比較で詳しく述べています。短く言えば、非同期ルールと豊かな条件ロジックが、検証ライブラリを残す 2 つの理由です。DbContext の呼び出しを IValidatableObject へ無理に押し込もうとしないでください。これは同期であり、スレッドをブロックするか、.Result によるデッドロックの領域へ押しやります。

検証: 一意性チェックは引き続き重複した SKU を拒否し(今度は 409、または選んだ任意のステータスとして)、初めての SKU は引き続き成功します。

5. 独自の ValidationAttribute でルールを再利用する

同じ手動チェックが 3 つのハンドラーに現れていたら(「この日付は過去にできない」)、IValidatableObject を 3 つの record にコピーしないでください。ValidationAttribute を 1 つ書けば、ジェネレーターは組み込み属性と同じように拾います。

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

これが、重複した if ブロックに対する属性ベースの答えです。ルールは一度だけ存在し、その型が使われるあらゆる場所に適用されます。

検証: 以前は同じ日付チェックを繰り返していたエンドポイントが、今はその属性で過去の日付を拒否し、その型を共有する他のエンドポイントはルールを継承します。

6. 死んだガードコードを削除し、戻り値型を単純化する

エンドポイントのルールが宣言的になったら、手動の if ブロックを完全に取り除き、ハンドラーの宣言された戻り値型を単純化します。400 はハンドラー本体が動く前にエンドポイントフィルターが生成するので、ハンドラーはその Results<...> ユニオンに BadRequest の分岐をもう必要としません。

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

検証: OpenAPI は引き続き(ProducesValidationProblem 経由で)400 を告知し、エンドポイントは取り除いたユニオン分岐なしでコンパイルされます。

検証

一群のエンドポイントを移行したら、完了と呼ぶ前にこのチェックリストを一通り確認します。

ロールバック計画

この移行は元に戻せて、さらに良いことに段階的なので、完全なロールバックが必要になることはめったにありません。2 つのレベルがあります。

私たちがぶつかった落とし穴

時間を要した実際の問題と、その解決策です。

持ち帰るべきメンタルモデルはこうです。この移行は新しいフレームワークの採用ではなく、命令的なガードコードを削除し、同じルールをリクエスト型上で宣言的に表現し直すことです。属性で表現できるルールは DataAnnotations へ、フィールド間のものは IValidatableObject へ、再利用可能なものは独自の ValidationAttribute へ移り、非同期のものは形がすでに有効と保証された後にハンドラー内で明示的なまま残ります。エンドポイントを 1 つずつ進め、それぞれを無効リクエストの呼び出しで検証すれば、各ハンドラーを開いていた if ブロックの定型コードは、ただ消えてなくなります。

関連

出典

Comments

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

< 戻る