ASP.NET Core(.NET 9)でDataAnnotationsのエラーメッセージを多言語化:IStringLocalizerと.resxで実装する完全ガイド

ASP.NET Core(.NET 9)の表単位バリデーションでよく使う [Required] や [EmailAddress] の英語メッセージを、利用者のカルチャー(en‑US / fr‑FR など)ごとに“自前の日本語・フランス語メッセージ”へ完全に差し替える方法を、MVC と Razor Pages の観点で実装パターン別に整理しました。PRG での二重送信対策、OnPostAsync の定石、安易な文字列置換の落とし穴まで一気通貫で解説します。

目次

結論(TL;DR)

アプリ全体の保守性と多言語対応のやりやすさを両立するには、次のいずれか(または併用)が定石です。

方法概要主なメリット主な注意点
公式推奨 : .resx リソースファイル方式1) Resources 配下に言語別 .resx を配置し、ErrorMessageResourceType/ErrorMessageResourceName で参照。
2) もしくは AddDataAnnotationsLocalization と DataAnnotationLocalizerProvider で、既定の DataAnnotations メッセージキー(例: RequiredAttribute_ValidationError)を自前リソースに向ける。
Visual Studio だけで翻訳管理が完結。
キーと値の不整合をビルド時に検出。
大規模・長期運用に強い。
.resx の Build Action を Embedded resource にすること。
既定メッセージのプレースホルダー({0} など)を正しく使うこと。
簡易版 : カスタム IStringLocalizer1) CustomStringLocalizer で Dictionary<string, Dictionary<string,string>> を持ち、キー×カルチャーで返す。
2) DataAnnotationLocalizerProvider へ差し込む。
小規模ならコードのみで完結。ファイルを増やしたくない場合に手軽。翻訳がソース埋め込みになり、量が増えると保守困難。翻訳ツール連携不可。

加えて、二重送信は PRG(Post‑Redirect‑Get)で「成功時は Redirect」に統一すると、エラー重複や連打問題を抑止できます(後述)。

前提の整理:どのメッセージを“どこで”変えるのか

ASP.NET Core の入力エラーメッセージは主に3層あります。どこを直すかを最初に決めると、設計がぶれません。

層例推奨アプローチ
DataAnnotations 層[Required] / [EmailAddress] / [StringLength] / [Range] など.resx で明示指定 or AddDataAnnotationsLocalization で既定キーを差し替え
モデルバインド層「’abc’ は数値ではありません」など型変換エラーModelBindingMessageProvider をローカライズ(後述の 全体差し替え)
ドメインルール層「同一メールは登録済み」「営業日は平日のみ」などIValidatableObject やサービス側の検証で独自メッセージを返す

プロジェクト初期設定(.NET 9 / MVC & Razor Pages 共通)

Program.cs でローカライズとカルチャー判定を有効化します。Cookie > QueryString > Accept-Language の順に優先する例です。

using System.Globalization;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// 1) リソースの場所
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");

// 2) MVC/Razor Pages + DataAnnotations ローカライズ
builder.Services
.AddControllersWithViews() // Razor Pages のみなら AddRazorPages()
.AddViewLocalization()
.AddDataAnnotationsLocalization(options =>
{
// DataAnnotations 既定メッセージを自前リソースに集約
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(ValidationMessages)); // Resources/ValidationMessages.*.resx
});

// 3) サポートするカルチャー
var supportedCultures = new[]
{
new CultureInfo("en-US"),
new CultureInfo("fr-FR"),
new CultureInfo("ja-JP")
};

builder.Services.Configure(options =>
{
options.DefaultRequestCulture = new RequestCulture("en-US");
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
// 優先順位: Cookie, QueryString, Accept-Language
options.RequestCultureProviders.Insert(0, new CookieRequestCultureProvider());
options.RequestCultureProviders.Insert(1, new QueryStringRequestCultureProvider());

});

var app = builder.Build();

// 4) 毎リクエストでカルチャーを適用
app.UseRequestLocalization(app.Services
.GetRequiredService>().Value);

app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();

// MVC
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");

// Razor Pages
app.MapRazorPages();

app.Run(); 

カルチャー切り替えリンク(Cookie で保持)

ユーザーが明示的に言語を選べるよう、Cookie を設定するアクションを用意します。

// MVC コントローラ例
[HttpPost]
public IActionResult SetLanguage(string culture, string returnUrl)
{
    Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) });
return LocalRedirect(returnUrl);

} 
&lt;form asp-controller="Home" asp-action="SetLanguage" method="post"&gt;
  &lt;select name="culture"&gt;
    &lt;option value="en-US"&gt;English&lt;/option&gt;
    &lt;option value="fr-FR"&gt;Français&lt;/option&gt;
    &lt;option value="ja-JP"&gt;日本語&lt;/option&gt;
  &lt;/select&gt;
  &lt;input type="hidden" name="returnUrl" value="@(Context.Request.Path + Context.Request.QueryString)" /&gt;
  &lt;button type="submit"&gt;Change&lt;/button&gt;
&lt;/form&gt;

方法A:各属性に .resx を明示指定(堅牢)

各プロパティに対し、ErrorMessageResourceType と ErrorMessageResourceName を指定する正攻法です。
プロパティ名の表示({0})も [Display] のリソースでローカライズできます。

public class UserViewModel
{
    [Display(Name = "Label_Email", ResourceType = typeof(ValidationMessages))]
    [Required(
        ErrorMessageResourceType = typeof(ValidationMessages),
        ErrorMessageResourceName = "Email_Required"
    )]
    [EmailAddress(
        ErrorMessageResourceType = typeof(ValidationMessages),
        ErrorMessageResourceName = "Email_Invalid"
    )]
    public string? Email { get; set; }
[Display(Name = "Label_Password", ResourceType = typeof(ValidationMessages))]
[Required(
    ErrorMessageResourceType = typeof(ValidationMessages),
    ErrorMessageResourceName = "Password_Required"
)]
[StringLength(100, MinimumLength = 8,
    ErrorMessageResourceType = typeof(ValidationMessages),
    ErrorMessageResourceName = "Password_Length")]
public string? Password { get; set; }

[Display(Name = "Label_ConfirmPassword", ResourceType = typeof(ValidationMessages))]
[Compare("Password",
    ErrorMessageResourceType = typeof(ValidationMessages),
    ErrorMessageResourceName = "Password_ConfirmMismatch")]
public string? ConfirmPassword { get; set; }

} 

Resources/ValidationMessages.resx(既定)、ValidationMessages.fr.resx、ValidationMessages.ja.resx といったファイルを用意し、次のようなキーを登録します。

キー値(例: ja-JP)備考
Label_Emailメールアドレス[Display] 用。{0} にはこの値が入る。
Email_Required{0} は必須です。{0} = Display 名
Email_Invalid{0} の形式が正しくありません。–
Password_Required{0} を入力してください。–
Password_Length{0} は {2}〜{1} 文字で入力してください。{1}=Max、{2}=Min
Password_ConfirmMismatch{0} が一致しません。{0}=ConfirmPassword の表示名

ビュー(共通)

<form asp-action="Register" method="post">
  <div asp-validation-summary="ModelOnly" class="text-danger"></div>







登録
 

方法B:既定メッセージをアプリ全体で差し替え(キー駆動)

「各属性に毎回 ErrorMessageResourceName を書くのは大変」という場合は、DataAnnotations の既定キーを丸ごと自前リソースへ向けると楽です。
先述の DataAnnotationLocalizerProvider 設定により、次のキーを ValidationMessages.*.resx に準備するだけで差し替わります。

属性キー名(代表例)プレースホルダー例(ja-JP)
RequiredRequiredAttribute_ValidationError{0}=表示名{0} は必須です。
EmailAddressEmailAddressAttribute_Invalid{0}=表示名{0} の形式が正しくありません。
StringLengthStringLengthAttribute_ValidationError{0}=表示名、{1}=Max、{2}=Min{0} は {2}〜{1} 文字で入力してください。
RangeRangeAttribute_ValidationError{0}=表示名、{1}=最小、{2}=最大{0} は {1} 以上 {2} 以下で入力してください。
CompareCompareAttribute_MustMatch{0}=表示名、{1}=比較先表示名{0} と {1} が一致しません。
RegularExpressionRegularExpressionAttribute_ValidationError{0}=表示名{0} の形式が正しくありません。

ポイントは、プロパティ側に [Display(Name="...", ResourceType=...)] を付けて表示名もローカライズすること。これにより {0} へ言語別のラベルが流れ、同じメッセージテンプレートでも自然な文章になります。

モデルバインド層のメッセージも忘れずに(型変換など)

「数値に文字列を入れた」など DataAnnotations 以外のエラーは ModelBindingMessageProvider で差し替えます。ローカライザを DI で受け取り、アプリ全体に適用する方法が定石です。

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;

public sealed class MvcModelBindingMessagesSetup : IConfigureOptions
{
private readonly IStringLocalizer _L;
public MvcModelBindingMessagesSetup(IStringLocalizer localizer) => _L = localizer;
public void Configure(MvcOptions options)
{
    options.ModelBindingMessageProvider.SetValueIsInvalidAccessor(_ =&gt; _L["ValueIsInvalid"]);
    options.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor((value, name) =&gt; _L["AttemptedValueIsInvalid", value, name]);
    options.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor(name =&gt; _L["MissingValue", name]);
    options.ModelBindingMessageProvider.SetValueMustNotBeNullAccessor(_ =&gt; _L["ValueMustNotBeNull"]);
    // 必要に応じて他のアクセサも
}

} 
// Program.cs
builder.Services.AddTransient&lt;IConfigureOptions&lt;MvcOptions&gt;, MvcModelBindingMessagesSetup&gt;();

ValidationMessages.*.resx に ValueIsInvalid や MissingValue などのキーを追加しておきます。

クライアント側(jQuery Validation)の整合性

ASP.NET Core の unobtrusive validation は、サーバーで生成した data-val-* 属性にメッセージ文字列を埋め込み、jQuery Validation がそれをそのまま表示します。つまり、サーバー側のローカライズさえ正しければ、クライアント側も同じ文言になります。別途 jQuery の言語ファイルを上書きする必要は通常ありません。

Razor Pages の OnPostAsync と PRG(二重送信対策)

二重送信やエラー重複を避けるには、「NG は同じページ、OK はリダイレクト」を徹底します。

public class RegisterModel : PageModel
{
    [BindProperty] public UserViewModel Input { get; set; } = new();
public void OnGet() { }

public async Task&lt;IActionResult&gt; OnPostAsync()
{
    if (!ModelState.IsValid)
    {
        // バリデーション NG:同じページを再表示(メッセージはそのまま)
        return Page();
    }

    // TODO: DB 登録など副作用
    TempData["Flash"] = "登録が完了しました。";

    // 成功時は PRG(リダイレクト)で二重送信と重複表示を回避
    return RedirectToPage("Success");
}

} 

クライアント側を補助するなら「送信ボタンを一時無効化」も有効ですが、最終的な二重防止はサーバー側(PRG)で担保してください。

&lt;button type="submit" class="btn btn-primary" data-disable-on-click="true"&gt;送信&lt;/button&gt;
&lt;script&gt;
document.addEventListener("click", function(e){
  const b = e.target.closest("button[data-disable-on-click='true']");
  if(!b) return;
  if(b.dataset.clicked) { e.preventDefault(); return; }
  b.dataset.clicked = "1";
  b.disabled = true;
});
&lt;/script&gt;

安易な「文字列置換」は避けるべき理由

アンチパターン: OnPost 後に ModelState のエラーメッセージ文字列を Replace で差し替えるやり方は、

  • カルチャーが増えるほど条件分岐が爆発する
  • フレームワークの文言変更(.NET 更新)で壊れる
  • クライアント側の data-val と不一致になる

結果として保守不能になります。必ずリソースまたはローカライザで「発生源」を置き換える方針にしましょう。

テストで守る:カルチャー別の回帰を防止

xUnit などで UICulture を切り替えて検証すると、翻訳の取りこぼしを自動検出できます。

[Fact]
public void Required_Message_Is_Localized_in_frFR()
{
    var model = new UserViewModel { Email = "" }; // Required
    var context = new ValidationContext(model);
    var results = new List<ValidationResult>();
var prev = CultureInfo.CurrentUICulture;
try
{
    CultureInfo.CurrentUICulture = new CultureInfo("fr-FR");
    Validator.TryValidateObject(model, context, results, validateAllProperties: true);
    Assert.Contains(results, r =&gt; r.ErrorMessage!.Contains("obligatoire") /* 例 */);
}
finally
{
    CultureInfo.CurrentUICulture = prev;
}

} 

実際のアプリでは DI でローカライザを使うため、WebApplicationFactory を用いた統合テストでカルチャー付きリクエストを投げ、HTML 内のエラーメッセージをアサートするのが堅い手です。

よくあるハマりどころと対処

  • リソースの Build Action が誤り:Embedded resource になっているか確認。
  • キーのスペル違い:.NET 8 以降のローカライズ用ソースジェネレータを使うと、キーの型安全性が上がり、打ち間違いを検出できます。
  • 表示名が英語のまま:[Display(Name="...", ResourceType=...)] を忘れると {0} に英語が混ざります。
  • カルチャーが切り替わらない:UseRequestLocalization() を Routing より前に置く。Cookie の優先順位を確認。
  • 複数回クリックでメッセージが増える:PRG に統一し、ModelState.IsValid で NG のときは同一ページを返す。

応用:全属性をコードで上書き(カスタムアダプタ)

より踏み込んだ全体制御として、IValidationAttributeAdapterProvider をラップして、メッセージ生成を一括でローカライズする手もあります。DataAnnotations のテンプレートを強制的に自前ロジックへ通したいときに有効です。

using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.Extensions.Localization;

public sealed class CustomAttributeAdapterProvider : IValidationAttributeAdapterProvider
{
private readonly IValidationAttributeAdapterProvider _base = new ValidationAttributeAdapterProvider();
private readonly IStringLocalizer _L;
public CustomAttributeAdapterProvider(IStringLocalizer&lt;ValidationMessages&gt; localizer)
    =&gt; _L = localizer;

public IAttributeAdapter? GetAttributeAdapter(ValidationAttribute attribute, IStringLocalizer stringLocalizer)
{
    // 例:Required のメッセージを強制的に置き換える
    if (attribute is RequiredAttribute req)
    {
        req.ErrorMessage = _L["RequiredAttribute_ValidationError"];
    }
    // 必要に応じて他の属性も…

    return _base.GetAttributeAdapter(attribute, stringLocalizer);
}

} 
// Program.cs
builder.Services.AddSingleton&lt;IValidationAttributeAdapterProvider, CustomAttributeAdapterProvider&gt;();

通常は B 方法(キー駆動の全体差し替え)で十分です。特殊要件があるときのみ検討してください。

Razor Pages / MVC の最小実装サンプル(ファイル構成)

MyApp/
 ├─ Program.cs
 ├─ Models/
 │   └─ UserViewModel.cs
 ├─ Resources/
 │   ├─ ValidationMessages.resx
 │   ├─ ValidationMessages.ja.resx
 │   └─ ValidationMessages.fr.resx
 ├─ Pages/ or Views/
 │   └─ Account/
 │       ├─ Register.cshtml
 │       └─ Success.cshtml
 └─ Controllers/(MVC の場合)
     └─ HomeController.cs(SetLanguage など)

プレースホルダー早見表

属性置換トークン意味例
Required{0}表示名{0} は必須です。
StringLength{0}, {1}, {2}表示名, 最大, 最小{0} は {2}〜{1} 文字で入力してください。
Range{0}, {1}, {2}表示名, 最小, 最大{0} は {1} 以上 {2} 以下で入力してください。
Compare{0}, {1}表示名, 比較相手の表示名{0} と {1} が一致しません。
RegularExpression{0}表示名{0} の形式が正しくありません。

小ネタ:.NET 8+

  • ローカライズのソースジェネレータ:IStringLocalizer のキーを定数化でき、未翻訳やタイプミスをビルド時に検出しやすくなります。長期運用の大規模案件では導入メリットが大きいです。
  • Identity のエラーメッセージ:ログイン/登録の標準メッセージは IdentityErrorDescriber を継承してローカライズ可能。フォームバリデーションと併せて統一感を出せます。

チェックリスト(最速で通すための要点)

  • builder.Services.AddLocalization(ResourcesPath="Resources") 済み
  • AddDataAnnotationsLocalization() を設定し、必要なら DataAnnotationLocalizerProvider で集約
  • UseRequestLocalization() をミドルウェアの最上流に
  • .resx の Build Action = Embedded resource
  • [Display(..., ResourceType=...)] でラベルもローカライズ
  • モデルバインド層は ModelBindingMessageProvider で置き換え
  • 成功時は PRG(Redirect)で二重送信を根絶

まとめ

DataAnnotations のメッセージを“あとから文字列置換”するのではなく、リソース(.resx)とローカライザで発生源を置き換えるのが ASP.NET Core/.NET 9 の王道です。
A(属性ごとの明示指定)は堅牢、B(既定キー一括差し替え)は省力。さらにモデルバインド層もカバーし、PRG で二重送信を断つ。ここまで整えると、カルチャーが増えてもコストは線形に増えるだけで、将来のメンテナンスも安心です。

この記事を書いた人

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

コメント

コメントする

目次