ASP.NET Core Web API では [ApiController] によりモデル バインドやバリデーションが失敗したとき自動で 400 Bad Request が返ります。しかしその JSON のフォーマットやメッセージはプロダクトごとに揃えたいことが多いはず。ここではフレームワークの仕組みを踏まえつつ、グローバル差し替え・局所制御・例外/監査との統一・多言語化まで、実運用に耐えるレベルで設定手順と実装のコツをまとめます。
なぜ既定の 400 を変えたくなるのか(仕組みの要点)
[ApiController] が付与されたコントローラでは、アクション到達前に入力モデルのバリデーションが行われ、失敗するとパイプラインがショートサーキットして フレームワークが 400 応答を生成します。既定フォーマットは RFC 7807 の ProblemDetails / ValidationProblemDetails で、代表的には以下のような JSON です。
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "|f7a49...-.",
"errors": {
"Email": [ "The Email field is required." ],
"Age": [ "The field Age must be between 0 and 200." ]
}
}
これを「自社のエラー規約(例:code・message 固定)」に合わせたい、traceId を含めたい/除外したい、日本語化したい、という要求に応えるのが本稿の目的です。
最短ルート:ApiBehaviorOptions.InvalidModelStateResponseFactory をグローバル差し替え
最も手軽で副作用が少ないのは InvalidModelStateResponseFactory を置き換える方法です。Program.cs(.NET 6 以降)で以下を追加します。
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// <--- ここがポイント
builder.Services.Configure(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
// ModelState からエラーを抽出(複数メッセージ対応)
var errors = context.ModelState
.Where(kv => kv.Value!.Errors.Count > 0)
.Select(kv => new
{
field = kv.Key,
messages = kv.Value!.Errors.Select(e => string.IsNullOrWhiteSpace(e.ErrorMessage)
? "入力値が不正です。"
: e.ErrorMessage)
});
// 共通エラー契約(例)
var response = new
{
code = "VALIDATION_FAILED",
message = "入力値が不正です。各フィールドのエラーを確認してください。",
status = StatusCodes.Status400BadRequest,
errors = errors
};
// 監査ログ/可観測性への出力(任意)
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("Validation");
logger.LogWarning("Validation failed. Path={Path}, Errors={@Errors}",
context.HttpContext.Request.Path, response.errors);
return new BadRequestObjectResult(response);
};
});
var app = builder.Build();
app.MapControllers();
app.Run();
これで すべての コントローラ/アクションに統一フォーマットの 400 が返るようになります。返却形は匿名型でも専用 DTO でも構いません。標準の ValidationProblemDetails をベースにしたい場合は、生成したうえで不要フィールドを無視して包み直すやり方も有効です。
返却例(統一フォーマット)
{
"code": "VALIDATION_FAILED",
"message": "入力値が不正です。各フィールドのエラーを確認してください。",
"status": 400,
"errors": [
{ "field": "Email", "messages": [ "必須項目です。" ] },
{ "field": "Age", "messages": [ "0 以上 200 以下で入力してください。" ] }
]
}
ポイント
- フィールド単位で 複数 メッセージを持てる構造にしておくと将来の拡張(ルール追加)でも破壊的変更を避けやすい。
- ログはここで一元化できる(監査・SIEM 連携・ダッシュボード集計)。
- 返却スキーマが決まっている場合は匿名型ではなく
record/クラス化しておくと OpenAPI スキーマにも載せやすい。
完全制御したい:自動 400 を抑止してアクションで返す
ファイルアップロードやストリーム処理など、モデル バインド前後で細かい分岐をしたい場合は自動 400 を止めて、アクション内で自分で返すのが安全です。
builder.Services.Configure<ApiBehaviorOptions>(o =>
o.SuppressModelStateInvalidFilter = true); // ←自動 400 を無効化
// 以降、各アクションで明示的に判定
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult Create(UserRequest req)
{
if (!ModelState.IsValid)
{
var errors = ModelState
.Where(x => x.Value!.Errors.Count > 0)
.Select(x => new { field = x.Key, messages = x.Value!.Errors.Select(e => e.ErrorMessage) });
return BadRequest(new
{
code = "VALIDATION_FAILED",
message = "入力値が不正です。",
status = 400,
errors
});
}
// 正常系...
return CreatedAtAction(nameof(Get), new { id = 1 }, new { id = 1 });
}
[HttpGet("{id}")]
public IActionResult Get(int id) => Ok(new { id });
}
この方式はエンドポイント単位での例外的要件(「この API は英語でだけ詳細なエラーを返す」など)にも対応しやすい反面、毎回同じコードを書きがちです。繰り返すならアクションフィルタ/ベースクラス/拡張メソッドで共通化しましょう。
もっと厳密に:ProblemDetailsFactory を差し替えて全エラー応答を統一
バリデーションエラーだけでなく、NotFound や Unauthorized、例外ハンドラからの Problem() など あらゆる エラー応答の形を統一したいなら ProblemDetailsFactory を自作/差し替えするのが堅実です。
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
public sealed class CustomProblemDetailsFactory : ProblemDetailsFactory
{
public override ProblemDetails CreateProblemDetails(
HttpContext httpContext,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null)
{
statusCode ??= StatusCodes.Status500InternalServerError;
var problem = new ProblemDetails
{
Status = statusCode,
Title = title ?? DefaultTitle(statusCode.Value),
Type = type ?? "about:blank",
Detail = detail,
Instance = instance ?? httpContext.Request.Path
};
// 例:共通拡張(トレース ID・独自コード付与)
problem.Extensions["traceId"] = httpContext.TraceIdentifier;
problem.Extensions["code"] = ToDomainCode(statusCode.Value);
return problem;
}
public override ValidationProblemDetails CreateValidationProblemDetails(
HttpContext httpContext,
ModelStateDictionary modelStateDictionary,
int? statusCode = null,
string? title = null,
string? type = null,
string? detail = null,
string? instance = null)
{
statusCode ??= StatusCodes.Status400BadRequest;
var validation = new ValidationProblemDetails(modelStateDictionary)
{
Status = statusCode,
Title = title ?? "入力値が不正です。",
Type = type ?? "about:blank",
Detail = detail,
Instance = instance ?? httpContext.Request.Path
};
validation.Extensions["traceId"] = httpContext.TraceIdentifier;
validation.Extensions["code"] = "VALIDATION_FAILED";
return validation;
}
private static string DefaultTitle(int status) => status switch
{
400 => "不正なリクエストです。",
401 => "認証が必要です。",
403 => "権限がありません。",
404 => "対象が見つかりません。",
_ => "エラーが発生しました。"
};
private static string ToDomainCode(int status) => status switch
{
400 => "BAD_REQUEST",
401 => "UNAUTHORIZED",
403 => "FORBIDDEN",
404 => "NOT_FOUND",
_ => "ERROR"
};
}
登録は DI に 1 行です。
builder.Services.AddSingleton<ProblemDetailsFactory, CustomProblemDetailsFactory>();
この方式の利点は、InvalidModelStateResponseFactory をカスタム DTO に置き換えるのではなく、標準の ProblemDetails を保ちつつ拡張できる点です。API の可観測性やクライアントの共通ハンドリングを壊さず、例外ハンドラ/フレームワーク既定の 400〜404 応答も横並びにできます。
多言語化(Accept-Language × CultureInfo)
国際向けフロントエンドでは、バリデーションメッセージの多言語化が必須です。ASP.NET Core はローカリゼーションを内蔵しており、InvalidModelStateResponseFactory の中でもロケールを参照できます。
builder.Services.AddLocalization();
builder.Services.AddControllers()
.AddDataAnnotationsLocalization(); // DataAnnotations の文言もローカライズ
var supported = new[] { "ja-JP", "en-US" }.Select(c => new CultureInfo(c)).ToList();
builder.Services.Configure(opt =>
{
opt.SupportedCultures = supported;
opt.SupportedUICultures = supported;
opt.ApplyCurrentCultureToResponseHeaders = true;
});
// InvalidModelStateResponseFactory 内
var culture = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName;
var message = culture == "en"
? "Validation failed. Please check field errors."
: "入力値が不正です。各フィールドのエラーを確認してください。";
DataAnnotations の属性([Required] など)に対しても ResourceType と ErrorMessageResourceName を指定すれば同様に翻訳できます。文言の責務をサーバーに持たせるか、エラーコードだけ返してクライアントで訳すかは、プロダクトの翻訳フローと相談するとよいでしょう。
OpenAPI/Swagger で仕様を同期させる
実装だけでなく API 仕様にも「カスタム 400 の形」を明示しておくと、社内外の利用者と齟齬がありません。コントローラで ProducesResponseType を付ける、あるいはスキーマを定義して流用すれば UI にも反映されます。
public sealed record ValidationError(string field, IEnumerable<string> messages);
public sealed record ValidationErrorResponse(string code, string message, int status, IEnumerable<ValidationError> errors);
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpPost]
[ProducesResponseType(typeof(ValidationErrorResponse), StatusCodes.Status400BadRequest)]
public IActionResult Create(UserRequest req) => Ok();
}
Swagger のサンプル(example)を設定しておくと、クライアント実装もスムーズです。
監査・モニタリングと相性のよい設計
- 全入力エラーを
ILoggerで構造化ログとして記録(@Errorsのように配列で残す)。 - エラー応答に
traceId(=HttpContext.TraceIdentifier)や相関 ID(ヘッダーX-Correlation-Idなど)を含めると、分散トレーシング/APM ツールとの突き合わせが容易。 - フィールド名は クライアントが解釈できるキー を使う(
User.Address[0].Zipのようなインデクサをそのまま返すか、正規化するかを最初に決めておく)。
Minimal API の場合は?(補足)
Minimal API は ModelState を直接使いません。DataAnnotations ベースの検証を自動で 400 にしたい/形を変えたいときは、エンドポイントフィルタでカプセル化すると綺麗にまとまります。
public sealed class ValidationFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var http = ctx.HttpContext;
// バインド済みの Body 引数と思しきレコードを探索して検証
foreach (var arg in ctx.Arguments)
{
if (arg is null) continue;
var context = new ValidationContext(arg);
var results = new List<ValidationResult>();
if (!Validator.TryValidateObject(arg, context, results, true))
{
var errors = results
.SelectMany(r => r.MemberNames.Select(m => new { field = m, message = r.ErrorMessage ?? "不正です。" }))
.GroupBy(x => x.field)
.Select(g => new { field = g.Key, messages = g.Select(x => x.message) });
return Results.BadRequest(new {
code = "VALIDATION_FAILED",
message = "入力値が不正です。",
status = 400,
errors
});
}
}
return await next(ctx);
}
}
// 登録
app.MapPost("/users", (UserRequest req) => Results.Ok())
.AddEndpointFilter();
この方式ならコントローラ式/Minimal 式のどちらでも「同じ形の 400」を返せます。
比較表:主なカスタマイズ手段
| 手段 | 適用範囲 | 利点 | 注意点 | こんな時に |
|---|---|---|---|---|
InvalidModelStateResponseFactory | ModelState 無効時のみ | 最小変更で全体適用。ログも一元化しやすい | 例外や 404 など他のエラー種別は別途調整が必要 | まず 400 の形だけ急いで整えたい |
SuppressModelStateInvalidFilter + アクション実装 | 対象アクション | 完全制御。特殊な前処理・条件分岐が可能 | 繰り返しが増える。書き忘れリスク | 一部 API だけ既定動作を外したい |
ProblemDetailsFactory 差し替え | すべてのエラー応答 | RFC 7807 を維持しつつ全体統一 | 実装コストはやや高い | プロダクト全体でエラー契約を厳密に統一 |
| Endpoint Filter(Minimal API) | Minimal 経路 | 軽量。既存コードに干渉しにくい | ModelState と異なるため設計切り分けが必要 | Minimal API で統一したい |
実務のベストプラクティス(チェックリスト)
- 固定のエラーコードを必ず返す(例:
VALIDATION_FAILED)。メッセージ差し替えや多言語化時もクライアントの分岐が壊れない。 - フィールド名は JSON プロパティ名 基準にそろえる(
[JsonPropertyName]を付けているならそちらを返す)。 - ネスト/配列は 「ドット + インデクサ」 で表現するか、階層構造のまま返すかをプロダクト方針として明文化する。
- セキュリティ観点から、内部実装に関する詳細な説明やスタックトレースは返さない(ログ側にのみ記録)。
- 公開 API の場合、スロットリング/レート制御のエラー形も合わせて定義(
429)。 - OpenAPI に 例(
example)を載せ、クライアント SDK 自動生成の整合性を担保。
よくある落とし穴と対策
エラーが 1 件しか返らない/抜け落ちる
ModelState から最初のエラーだけを取り出していると起きます。上記サンプルのように Errors.Select(...) で配列化して返す設計にしましょう。
ファイルアップロード(IFormFile)で 415 になる
コンテンツタイプや [FromForm] の指定が合っていない可能性があります。自動 400 を抑止してアクション内で分岐すれば、フォーマットの差異にも柔軟に対応できます。
クライアントが PascalCase/ camelCase を要求
サーバーの応答フィールド名は System.Text.Json のオプション(JsonSerializerOptions.PropertyNamingPolicy)に従います。ProblemDetails を使う場合も影響を受けるため、契約に合わせて builder.Services.Configure<HttpJsonOptions> または AddControllers().AddJsonOptions(...) を設定しましょう。
既定の ProblemDetails を使いたいが項目を間引きたい
独自の DTO に詰め替えるか、ProblemDetailsFactory を差し替え、不要な拡張フィールドを付けないようにします。JsonIgnore での削除は 返却時 に限られるため、生成段階で作らないことが最も確実です。
動作確認テンプレート(統合テスト)
CI で形の破壊的変更を検知できるよう、統合テストを 1 本用意しておくと安心です。
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class ValidationTests : IClassFixture>
{
private readonly HttpClient _client;
public ValidationTests(WebApplicationFactory factory) => _client = factory.CreateClient();
[Fact]
public async Task Returns_Custom_Validation_Error_Format()
{
var res = await _client.PostAsJsonAsync("/api/users", new { Email = "", Age = -1 });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("VALIDATION_FAILED", json.GetProperty("code").GetString());
Assert.True(json.GetProperty("errors").EnumerateArray().Any());
}
}
仕上げ:実務向けサンプル一式
以下を そのまま プロジェクトに追加すれば、ModelState 無効時の 400 が統一され、例外/その他のエラーも ProblemDetails ベースで揃います。
Program.cs(抜粋)
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLocalization();
builder.Services.AddControllers().AddDataAnnotationsLocalization();
// JSON ポリシー(camelCase 例)
builder.Services.AddControllers().AddJsonOptions(o =>
{
o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
});
// ModelState 無効時の応答差し替え
builder.Services.Configure(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(x => x.Value!.Errors.Count > 0)
.Select(x => new { field = x.Key, messages = x.Value!.Errors.Select(e => e.ErrorMessage) });
var response = new
{
code = "VALIDATION_FAILED",
message = Localize(context.HttpContext, "入力値が不正です。"),
status = 400,
errors
};
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("Validation");
logger.LogWarning("Validation failed {@Errors}", response.errors);
return new BadRequestObjectResult(response);
};
});
// RFC7807 ベースの全体統一
builder.Services.AddSingleton();
var app = builder.Build();
app.UseRequestLocalization(); // 多言語対応
app.MapControllers();
app.Run();
// 多言語メッセージ(簡易版)
static string Localize(HttpContext http, string ja) =>
Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "en"
? "Validation failed. Please check field errors."
: ja;
サンプル DTO とバリデーション
public sealed class UserRequest
{
[Required(ErrorMessage = "必須項目です。")]
[EmailAddress(ErrorMessage = "メールアドレスの形式が正しくありません。")]
public string Email { get; init; } = default!;
[Range(0, 200, ErrorMessage = "{0} は {1}〜{2} の範囲で入力してください。")]
public int Age { get; init; }
}
まとめ
ASP.NET Core の既定動作は堅牢ですが、プロダクトの「エラー契約」を守るには少しのカスタマイズが必要です。まずは InvalidModelStateResponseFactory で統一し、全エラーを揃えたいなら ProblemDetailsFactory で一段引き上げ、特殊要件は自動 400 を抑止。これらをローカリゼーションとロギングで包むと、要件変化にも強い設計になります。今日からプロジェクトに取り込み、API の使い勝手と保守性を一気に改善しましょう。

コメント