Skip to content

[API Proposal] Localization support for Microsoft.Extensions.Validation #66392

@oroztocil

Description

@oroztocil

Background and Motivation

Microsoft.Extensions.Validation currently provides no localization or message customization features beyond those built into System.ComponentModel.DataAnnotations. The only DataAnnotations localization mechanism is static property localization via ErrorMessageResourceType/ErrorMessageResourceName, which requires annotating each attribute instance individually — a verbose approach that also requires recompilation and is impractical for loading translations from databases, JSON files, or other external sources.

ASP.NET Core MVC worked around this with its own IStringLocalizer-based adapter system, but it is tightly coupled to MVC's model metadata pipeline and cannot be reused in Minimal APIs, Blazor, or non-web hosts.

This proposal adds localization support to Microsoft.Extensions.Validation that:

  • Integrates with Microsoft.Extensions.Localization (IStringLocalizer) for runtime localization of error messages and display names
  • Provides a mechanism for formatting localized templates with attribute-specific arguments (a framework-indpendent replacement for MVC's attribute adapter system)
  • Supports convention-based key selection without requiring explicit ErrorMessage on every attribute instance
  • Works consistently across Minimal APIs, Blazor, and any other consumer of Microsoft.Extensions.Validation

Issues:

Proposed API

1. IStringLocalizer resolution configuration

namespace Microsoft.Extensions.Validation;

public class ValidationOptions
{
+    /// <summary>
+    /// Controls which IStringLocalizer is used for a given declaring type.
+    /// </summary>
+    public Func<Type, IStringLocalizerFactory, IStringLocalizer>? LocalizerProvider { get; set; }
}

2. Message formatting with attribute-specific arguments

namespace Microsoft.Extensions.Validation;

+/// <summary>
+/// Formats a localized error message template with attribute-specific arguments.
+/// Replaces the MVC adapter pattern with a simpler, framework-independent interface.
+/// </summary>
+[Experimental]
+public interface IValidationAttributeFormatter
+{
+    string FormatErrorMessage(CultureInfo culture, string messageTemplate, string displayName);
+}

+/// <summary>
+/// Keyed registry of attribute formatter factories. Built-in formatters for standard
+/// attributes with multi-placeholder templates are registered automatically.
+/// </summary>
+[Experimental]
+public sealed class ValidationAttributeFormatterRegistry
+{
+    public void AddFormatter<TAttribute>(Func<TAttribute, IValidationAttributeFormatter> factory)
+        where TAttribute : ValidationAttribute;
+    public IValidationAttributeFormatter? GetFormatter(ValidationAttribute attribute);
+}

public class ValidationOptions
{
+    [Experimental]
+    public ValidationAttributeFormatterRegistry AttributeFormatters { get; }
}

3. Optional improvement: Programmatic error message keys

namespace Microsoft.Extensions.Validation;

public class ValidationOptions
{
+    /// <summary>
+    /// Controls the resource key used to look up the error message template.
+    /// Called as a fallback for attributes without an explicit ErrorMessage,
+    /// enabling convention-based key selection.
+    /// </summary>
+    public Func<ErrorMessageKeyContext, string?>? ErrorMessageKeyProvider { get; set; }
}

+public readonly struct ErrorMessageKeyContext
+{
+    public ValidationAttribute Attribute { get; init; }
+    public Type? DeclaringType { get; init; }
+    public string DisplayName { get; init; }
+    public string MemberName { get; init; }
+}

API details

ValidationOptions.LocalizerProvider — Controls which IStringLocalizer is used for a given declaring type. The delegate receives the declaring type and an IStringLocalizerFactory, and returns the IStringLocalizer to use. When null (the default), localization is disabled and validation messages use the default DataAnnotations behavior. Typical configurations: per-type lookup via (type, factory) => factory.Create(type), or shared resource via (_, factory) => factory.Create(typeof(SharedResource)).

ValidationOptions.ErrorMessageKeyProvider — Controls the resource key used for localization lookup. Called as a fallback when an attribute does not have an explicit ErrorMessage set, enabling convention-based key selection (e.g., context => $"{context.Attribute.GetType().Name}_Error"). When null (the default), only attributes with ErrorMessage set are localized. When both ErrorMessage and ErrorMessageKeyProvider are available, ErrorMessage takes precedence. This addresses a long-standing request to localize built-in attribute messages without modifying model classes:

ErrorMessageKeyContext — Context struct passed to the ErrorMessageKeyProvider delegate. Contains the ValidationAttribute instance, the declaring type (or null for top-level parameter validation), the resolved display name, and the member name.

IValidationAttributeFormatter — Formats a localized error message template with attribute-specific arguments. This replaces MVC's adapter system with a simpler, framework-independent interface. For example, RangeAttribute's template "The {0} field must be between {1} and {2}." requires the min/max values as {1} and {2}. The formatter receives the culture, the localized template, and the display name, and returns the fully formatted message.

ValidationAttributeFormatterRegistry — Keyed registry mapping attribute types to formatter factories. Resolution order: (1) if the attribute implements IValidationAttributeFormatter itself (self-formatting), it is returned directly; (2) if a factory is registered for the attribute type, it creates a formatter; (3) null is returned — the pipeline falls back to formatting with only the display name.

Usage Examples

Localization is enabled by registering an IStringLocalizerFactory implementation into DI (e.g., by calling AddLocalization()). When IStringLocalizerFactory is available, the validation pipeline automatically uses it to localize error messages and display names. No localization occurs when the factory is not registered.

Localizer configuration

Enable localization with per-type resource files (default):

builder.Services.AddLocalization();
builder.Services.AddValidation();

Use a shared resource file:

builder.Services.AddLocalization();
builder.Services.AddValidation(options =>
{
    options.LocalizerProvider = (_, factory) => factory.Create(typeof(ValidationMessages));
});

Use a custom IStringLocalizer:

builder.Services.AddSingleton<IStringLocalizerFactory, JsonStringLocalizerFactory>();
builder.Services.AddValidation();

Attribute-specific formatting

Self-formatting custom attribute:

class CustomRangeAttribute : ValidationAttribute, IValidationAttributeFormatter
{
    public int Min { get; }
    public int Max { get; }

    public string FormatErrorMessage(CultureInfo culture, string messageTemplate, string displayName)
        => string.Format(culture, messageTemplate, displayName, Min, Max);
}

External formatter registration (when you don't control the attribute source):

builder.Services.AddValidation(options =>
{
    options.AttributeFormatters.AddFormatter<CustomAttribute>(a => new CustomAttributeFormatter(a))
});

Programmatic message keys

Convention-based key selection (localize built-in attributes without explicit ErrorMessage):

builder.Services.AddValidation(options =>
{
    options.ErrorMessageKeyProvider = context =>
        $"{context.Attribute.GetType().Name}_Error";
});

// Now [Required] looks up "RequiredAttribute_Error" in the resource file,
// without needing [Required(ErrorMessage = "RequiredAttribute_Error")] on every property.

Alternative Designs

LocalizerProvider placement — core package vs separate localization package. The current proposal places LocalizerProvider directly on ValidationOptions in the core Microsoft.Extensions.Validation package. An alternative is to put it in a separate ValidationLocalizationOptions class in a new Microsoft.Extensions.Validation.Localization package, which would avoid the core package taking a dependency on Microsoft.Extensions.Localization.Abstractions. The direct placement was chosen for simplicity — a single AddValidation(options => ...) call configures everything.

Named delegate types vs Func<>. Named delegates (e.g., delegate string? ErrorMessageKeyProvider(in ErrorMessageKeyContext context)) could provide better readability and support in (pass-by-reference) parameters for the ErrorMessageKeyContext struct. Func<> was chosen for simplicity, following the framework design guidelines.

ValidationAttributeFormatterRegistry vs DI-based formatter resolution. An alternative is to register formatters via DI (e.g., services.AddSingleton<IValidationAttributeFormatter<RangeAttribute>, RangeAttributeFormatter>()). The registry approach was chosen because formatters are attribute-instance-specific — they need access to the attribute's property values (e.g., RangeAttribute.Minimum) — so a factory pattern (Func<TAttribute, IValidationAttributeFormatter>) is a natural fit. A keyed registry on ValidationOptions keeps this self-contained without introducing a generic DI service resolution pattern.

Risks

  • LocalizerProvider ties core package to IStringLocalizer types. The Func<Type, IStringLocalizerFactory, IStringLocalizer> signature introduces a reference to Microsoft.Extensions.Localization.Abstractions in the core Microsoft.Extensions.Validation package. This could be avoided by placing LocalizerProvider in a separate options class in a localization-specific package, at the cost of more complex setup.

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-proposalapi-ready-for-reviewAPI is ready for formal API review - https://github.com/dotnet/apireviewsarea-blazorIncludes: Blazor, Razor Componentsarea-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcfeature-validationIssues related to model validation in minimal and controller-based APIs

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions