ASP.NET CoreのBadRequestが2種類になる原因と統一方法(errors付き/なし)

ASP.NET Core Web API で 400 BadRequest を返しているのに、ある時は errors キー付きの ProblemDetails、別の時は ModelState がそのままの JSON になって困ったことはありませんか?本記事では原因の切り分け方と、レスポンス形式を安全に統一する実装パターンを具体例で解説します。

目次

現象:同じ BadRequest のはずなのにレスポンスが2種類に見える

まず最初に整理したいのは、「あなたが返している 400」と「フレームワークが返している 400」が混ざると、見た目が簡単にブレるという点です。特に ASP.NET Core の Web API で [ApiController] が付いている場合、モデル検証(Model Validation)に失敗すると、アクションに入る前に 自動で 400 が返ることがあります。

その結果、次のような“二重構造”が起きます。

  • パターンA:アクション実行前に返る「自動 400」→ ValidationProblemDetails(errors を持つ ProblemDetails) になりやすい
  • パターンB:アクション内で返す「手動 400」→ ModelState(SerializableError相当)をそのまま JSON 化 しやすい

パターンA:自動 400(errors でラップされた ProblemDetails 形式)

[ApiController] が有効なコントローラーで、リクエストのモデル検証が失敗すると、代表的には次のような JSON が返ります(環境やバージョン、設定により細部は変わります)。

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-...-...",
  "errors": {
    "Username": [
      "The Username field is required."
    ],
    "Password": [
      "The Password field is required."
    ]
  }
}

注目ポイントは errors がトップレベルに存在すること、そして traceId などのメタ情報が付くことです。API クライアント側では「問題詳細(Problem Details)」として扱いやすい反面、BadRequest(ModelState) の JSON と形が変わるため、パーサーが分岐しがちです。

パターンB:手動 400(errors が付かない ModelState 直列化形式)

一方で、アクション内で明示的に BadRequest(ModelState) を返した場合、次のように「辞書そのもの」に近い形になりやすいです。

{
  "Username": [
    "The Username field is required."
  ],
  "Password": [
    "The Password field is required."
  ]
}

こちらには type/title/traceId といったメタ情報が基本的に含まれません。また、トップレベルが “エラー辞書” になるため、クライアント実装によっては扱いやすい一方、ProblemDetails と混ざると統一感が崩れます。

まず切り分け:アクションに入っているかどうかで「自動 400」か判別できる

レスポンス形式が揺れるとき、最短で原因を特定するコツはシンプルです。ブレークポイントをアクション先頭に置き、「リクエストがそこまで到達しているか」を見るだけで、ほとんどの場合は正体が分かります。

確認ポイント到達していない(自動 400 の可能性大)到達している(手動 400 の可能性大)
アクション先頭にブレークポイント止まらない止まる
返ってくる JSONerrors が付く ProblemDetails 形式が多いエラー辞書(ModelState直列化)が多い
Content-Typeapplication/problem+json になりがちapplication/json になりがち

「if (!ModelState.IsValid) return BadRequest(ModelState); を書いているのに、errors が付いたレスポンスが返ってくる」というケースは、その if 文が実行される前にフレームワークが 400 を返している可能性が高いです。コードが存在していても、アクションに入らなければ実行されません。

なぜ起きるのか:[ApiController] の自動モデル検証と 400 応答

ASP.NET Core の Web API で [ApiController] を使うと、次のような “おせっかい” が有効になります。

  • 要求パラメーターのバインド失敗や検証失敗を、アクション実行前にまとめて処理してくれる
  • ModelState が不正なら、デフォルトでは自動で 400 を返す
  • その際のレスポンスは ProblemDetails(特に検証の場合は ValidationProblemDetails)になりやすい

この自動 400 は便利な一方、アクション内で BadRequest(ModelState) を返す“自前の運用”と混ざると、レスポンススキーマが二種類になります。結果として、フロントエンド・モバイルアプリ・外部連携のクライアントが、「400 のパースだけ例外対応」を強いられます。

errors が付く/付かないの正体:ValidationProblemDetails と SerializableError

形式の差は、突き詰めると「どの型を JSON 化しているか」の差です。

返しているもの代表的な型JSON の見た目特徴
自動 400([ApiController])ValidationProblemDetails(ProblemDetails){ ..., "errors": { ... } }メタ情報が付く。クライアントで共通処理しやすい
BadRequest(ModelState)SerializableError 相当(辞書){ "Field": ["..."], ... }シンプルだが、ProblemDetails と混ざると分岐が必要

つまり、同じ 400 でも「返すオブジェクト」が違うため JSON が違います。HTTP ステータスコードが同じでも、ボディは別物です。

「キーが増える」「空文字キー \"\" が出る」の正体:AddModelError のキー設計

ModelState.AddModelError で追加する “キー” は、エラーがどこに紐づくかを表現します。ここが曖昧だと、レスポンスが意図せず増えたり、空文字のキーが出たりします。

追加例意味レスポンスでの見え方UI 表示の向き不向き
ModelState.AddModelError("Username", "…")Username フィールドに紐づくエラー"Username": ["…"]フォームの入力欄の下に出しやすい
ModelState.AddModelError("", "…")モデル全体(特定フィールドに紐づかない)エラー"": ["…"]画面上部の共通エラーに出しやすいが、空キーを嫌うクライアントもある
ModelState.AddModelError("user.username", "…")ネストしたプロパティのパス"user.username": ["…"]JSON の構造と合わせると分かりやすい

「既存の検証エラー(例:"Username")に加えて、手動で "" のエラーを追加したらキーが増えた」というのは自然な挙動です。ModelState は “複数のエラーソースの寄せ集め” なので、追加した分だけ辞書が増えます。

レスポンス形式を揃える方針は3つ。おすすめは「ProblemDetails へ寄せる」

API の利用者(フロント、外部パートナー、将来の自分)が困らないようにするには、400 のレスポンススキーマを 1 つに固定するのが最重要です。代表的な方針は次の 3 つです。

方針やることメリット注意点
自動 400 を維持し、手動も ProblemDetails に寄せるBadRequest(ModelState) ではなく ValidationProblem(ModelState) を返すデフォルト挙動と整合。traceId なども揃うクライアント側は ProblemDetails 前提で実装する
自動 400 を無効化し、必ず手動で返すSuppressModelStateInvalidFilter を有効にし、全アクションで統一チェック制御が分かりやすい。辞書形式に固定しやすいチェック漏れがバグになる。共通化(フィルター等)が必須
InvalidModelStateResponseFactory でカスタムスキーマに固定自動 400 の出力をアプリ独自形式に変換既存クライアント仕様に合わせやすいProblemDetails の互換性を捨てる場合、将来の拡張で負債になることも

結論としては、特別な事情(既存仕様の互換、外部契約)がない限り、「自動 400 を維持し、手動も ProblemDetails へ寄せる」が最もトラブルが少ないです。なぜなら、400 だけでなく 404/409/500 など、他のエラーにも ProblemDetails を横展開しやすくなるからです。

方法1:手動の返し方を ValidationProblem(ModelState) に統一する

コントローラー(ControllerBase)では、検証エラー用に ValidationProblem が用意されています。これを使うと、手動 400 でも errors を持つ ValidationProblemDetails 形式に寄せられます。

コントローラー内の例

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    [HttpPost("register")]
    public IActionResult Register([FromBody] RegisterRequest request)
    {
        if (!ModelState.IsValid)
        {
            // BadRequest(ModelState) ではなく、ValidationProblem を使う
            return ValidationProblem(ModelState);
        }


    // 例:ビジネスルールの検証(重複チェックなど)
    if (request.Username == "admin")
    {
        ModelState.AddModelError(nameof(request.Username), "そのユーザー名は使用できません。");
        return ValidationProblem(ModelState);
    }

    return Ok();
}


}

このように、検証エラーは一貫して ValidationProblem で返すと、レスポンスが揺れにくくなります。特に「既存の検証エラー + 追加したビジネスエラー」を同じボディにまとめたい場合、ModelState に加えてから ValidationProblem(ModelState) を返すのは相性が良いです。

空文字キーを避けたい場合の工夫

ModelState.AddModelError("", "...") は “モデル全体エラー” としては正しいのですが、クライアント側で空キーを扱いづらいことがあります。その場合は、最初から “全体用のキー” を決めておくと運用が安定します。

// 例:全体エラー用のキーを "_global" に固定する
ModelState.AddModelError("_global", "ユーザー名またはパスワードが正しくありません。");
return ValidationProblem(ModelState);

このようにしておくと、フロント側で「_global は画面上部へ」「フィールド名は各入力欄へ」という UI 実装が素直になります。

方法2:自動 400 を抑止し、必ずアクション内で統一チェックする

「とにかくレスポンス形を辞書形式に固定したい」「ProblemDetails に寄せるとクライアント変更が大きい」などの事情がある場合は、自動 400 を止めて運用を統一する方法があります。

Program.cs(または Startup)で自動 400 を無効化

builder.Services
    .AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        // ModelState が不正でもアクションへ入るようにする
        options.SuppressModelStateInvalidFilter = true;
    });

この設定を入れると、ModelState が不正でもアクションが実行されます。あとはすべてのアクション(あるいは共通フィルター)で、次のように統一チェックを行います。

if (!ModelState.IsValid)
{
    return BadRequest(ModelState);
}

ただしこの方針の最大のリスクは、チェック漏れがそのままバグになることです。チーム開発では「書き忘れ」や「例外的なアクションだけ抜ける」事故が起きやすいので、次のような対策がセットになります。

  • 共通のアクションフィルターで ModelState を検査して自動で返す(= 自分で自動化する)
  • コーディング規約・レビュー観点に必ず入れる
  • 統合テストで 400 の形を検証する

方法3:自動 400 の出力をカスタム化して「常に同じ契約」に固定する

既存クライアントが「トップレベルが errors 辞書ではないと困る」など、明確な契約がある場合は、[ApiController] の自動 400 を利用しつつ、返す JSON をアプリ独自の形に変換できます。ポイントは InvalidModelStateResponseFactory です。

例:自動 400 を “自社フォーマット” に統一する

ここでは例として、次のような形式に統一するイメージを示します。

{
  "code": "validation_failed",
  "message": "入力内容に誤りがあります。",
  "errors": {
    "Username": ["..."],
    "_global": ["..."]
  },
  "traceId": "..."
}

これを自動 400 に適用する実装例です。

builder.Services
    .AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var errors = context.ModelState
                .Where(kvp => kvp.Value?.Errors.Count > 0)
                .ToDictionary(
                    kvp => kvp.Key,
                    kvp => kvp.Value!.Errors.Select(e => e.ErrorMessage).ToArray()
                );


        var payload = new
        {
            code = "validation_failed",
            message = "入力内容に誤りがあります。",
            errors,
            traceId = context.HttpContext.TraceIdentifier
        };

        return new BadRequestObjectResult(payload);
    };
});

この方法なら、アクションに入る前に返る 400 も、常に同じ形になります。さらに手動で返す場合も、同じペイロード生成処理を共通メソッド化して使えば完全に揃います。

ただし注意点として、ProblemDetails を標準で期待しているツールやクライアント(一般的な API クライアント、将来の別チームなど)から見ると、標準仕様から外れるため、API 契約書(OpenAPI/Swagger)で明示しておくことをおすすめします。

実務で効く「統一」設計:検証エラーとビジネスエラーを分ける

API 設計でよくある悩みが、「入力検証(validation)とビジネスルール違反(business rule)を、どちらも 400 に寄せていいのか?」という点です。現場の運用としては、次のように分けると混乱が減ります。

エラーの種類例おすすめのステータス返し方の例
入力検証エラー必須項目未入力、文字数超過、形式不正400ValidationProblem(ModelState)
ビジネスルール違反ユーザー名重複、権限不足、状態不整合409 / 403 などProblem(...) やカスタム結果

もちろんプロダクト事情で「重複も 400 に寄せる」ケースはありますが、その場合でも レスポンススキーマだけは統一しておくと、クライアント側の実装が簡単になります。

クライアント側の実装を壊さないためのチェックポイント

レスポンス形式を統一する前に、実際の利用側(フロント、アプリ、他サービス)がどうパースしているかを確認しましょう。特に次の観点は“後から事故りやすい”ポイントです。

  • Content-Type が application/problem+json の場合に、JSON パーサーや型定義が対応できているか
  • 空文字キー("")をマップできない言語・ライブラリがないか(例:一部のコードジェネレーター)
  • エラー辞書が string[] 前提になっていて、将来拡張(コード、メタ情報)で壊れないか
  • UI 表示が「フィールド別」と「全体」の両方に対応できているか

「今は動いている」実装ほど、形式が増えると破綻しやすいので、API 側で揺れを無くす価値は大きいです。

よくある落とし穴:統一したつもりでも揺れるケース

最後に、統一したはずなのに “また揺れる” 典型例をまとめます。

症状原因対策
一部のアクションだけ errors 形式にならないBadRequest(ModelState) が残っている/ヘルパー未統一検索して置換、共通メソッド化、レビュー観点に入れる
同じ入力でも環境により JSON が違う例外ハンドリングや ProblemDetails の設定差、ミドルウェア構成差Program.cs の差分をなくし、統合テストで検証する
"" キーのせいでクライアントが落ちるモデル全体エラーを空キーで返している_global などの固定キーに寄せる/変換する
エラー文言が二重に出るDataAnnotations の検証と手動エラーを同じフィールドに追加しているどちらを正とするか決め、重複を抑制する

まとめ:最初に「自動 400」か「手動 400」かを揃えると一気に解決する

ASP.NET Core の 400 が二種類に見える問題は、ほとんどの場合「自動 400([ApiController])と手動 400(BadRequest(ModelState))が混在している」ことが原因です。アクションに入っているかどうかを確認し、どちらの方針で統一するかを決めましょう。

  • デフォルトの流儀に寄せるなら:ValidationProblem(ModelState) を使って ProblemDetails に統一
  • 辞書形式に寄せるなら:自動 400 を抑止して運用を統一(チェック漏れ対策は必須)
  • 契約が決まっているなら:InvalidModelStateResponseFactory で自動 400 をカスタム化

いずれにしても、API の利用者が「400 のときだけ分岐」しなくて済む状態を作ることが、保守性と開発速度に直結します。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次