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 の可能性大) |
|---|---|---|
| アクション先頭にブレークポイント | 止まらない | 止まる |
| 返ってくる JSON | errors が付く ProblemDetails 形式が多い | エラー辞書(ModelState直列化)が多い |
| Content-Type | application/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 に寄せていいのか?」という点です。現場の運用としては、次のように分けると混乱が減ります。
| エラーの種類 | 例 | おすすめのステータス | 返し方の例 |
|---|---|---|---|
| 入力検証エラー | 必須項目未入力、文字数超過、形式不正 | 400 | ValidationProblem(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 のときだけ分岐」しなくて済む状態を作ることが、保守性と開発速度に直結します。

コメント