ASP.NET Core Web APIのModelState無効時400エラーをカスタマイズする完全ガイド|ApiBehaviorOptions・ProblemDetails・多言語対応

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」を返せます。

比較表:主なカスタマイズ手段

手段適用範囲利点注意点こんな時に
InvalidModelStateResponseFactoryModelState 無効時のみ最小変更で全体適用。ログも一元化しやすい例外や 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 の使い勝手と保守性を一気に改善しましょう。

この記事を書いた人

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

コメント

コメントする

目次