Repository navigation
Description
Acknowledgement: The proposed API shape was brought forward by @oroztocil, building on his MEV localization work in #65539, #66646, and #67987.
Background and motivation
The only built-in localization mechanism in System.ComponentModel.DataAnnotations is static resource-based localization via ErrorMessageResourceType/ErrorMessageResourceName (and the analogous DisplayAttribute.ResourceType). This approach must be configured on each attribute instance, is typically backed by compile-time generated .resx resources, and does not support runtime localization sources such as databases, JSON, or PO files. The alternative, assigning a literal ErrorMessage, produces a fixed message with no localization support.
More importantly, even when a framework resolves a localized message template at runtime, it cannot delegate formatting back to the attribute. ValidationAttribute.FormatErrorMessage(string name) only formats the attribute's own internally selected template, while attribute-specific placeholders remain encapsulated within the implementation. For example, RangeAttribute formats "The {0} field must be between {1} and {2}.", where {1} and {2} correspond to Minimum and Maximum.
Because DataAnnotations does not expose a formatting primitive for externally supplied templates, every consumer must duplicate the same attribute-specific formatting logic:
- MVC, a per-attribute adapter (
RangeAttributeAdapter,StringLengthAttributeAdapter, ...). - Microsoft.Extensions.Validation (MEV), first a formatter registry plus built-in formatters dotnet/aspnetcore#66646, then a generated per-type switch dotnet/aspnetcore#67987.
- Blazor SSR, duplicates that same switch.
This duplicated knowledge can drift from the underlying attribute implementation, forcing upper layers to hardcode built-in arguments rather than reuse the formatting logic already defined by the attribute itself. For example, FileExtensionsAttribute.FormatErrorMessage fills {1} with its normalized, dot-prefixed list (ExtensionsFormatted, e.g. .png, .jpg), while the MEV and Blazor switches fill {1} with the raw Extensions property (e.g. png,jpg), so the upper-layer copies and the real attribute can emit different text for the same attribute.
Adding the formatting primitive to DataAnnotations directly benefits both built-in and custom attributes (and plain Validator.TryValidateObject), and it lets callers source the message template from any provider at runtime (.resx, IStringLocalizer, PO files, a database, JSON) instead of being limited to the compile-time ErrorMessage literal or the resx-only resource path.
This proposal adds only the missing DataAnnotations formatting primitive so the upper layers can stop hardcoding built-in arguments. It does not move key selection or IStringLocalizer into DataAnnotations, and it does not add a Microsoft.Extensions.Localization dependency. The localization/key work is already tracked and shipping in the upper layers.
Related / history tracker: the localization-key and IStringLocalizer work originated from earlier requests such as dotnet/aspnetcore#12158 and dotnet/aspnetcore#4848, was formalized in dotnet/aspnetcore#65539, which identified the inability of ValidationAttribute.FormatErrorMessage(string name) to accept externally localized templates as the reason MVC required attribute adapters, and was later developed through the approved MEV proposal (dotnet/aspnetcore#66392).
API Proposal
The core addition is a single new method on the base class, plus a change to the default FormatErrorMessage so it routes through it:
namespace System.ComponentModel.DataAnnotations;
public abstract partial class ValidationAttribute
{
// EXISTING, default implementation changes to delegate to the new method:
// public virtual string FormatErrorMessage(string name) => FormatMessage(ErrorMessageString, name);
// public virtual string FormatErrorMessage(string name);
public virtual string FormatMessage(string messageString, string name);
}Making the built-in attributes format a caller-supplied template correctly is then an additive follow-on. Each built-in attribute whose template uses placeholders beyond {0} overrides FormatMessage to project its own arguments. These overrides add a member and remove nothing, so they can ship in the same change or land incrementally afterward without breaking anyone:
namespace System.ComponentModel.DataAnnotations;
public partial class CompareAttribute
{
// EXISTING
// public override string FormatErrorMessage(string name);
public override string FormatMessage(string messageString, string name);
}
public sealed partial class FileExtensionsAttribute
{
// EXISTING
// public override string FormatErrorMessage(string name);
public override string FormatMessage(string messageString, string name);
}
public partial class LengthAttribute
{
// EXISTING
// public override string FormatErrorMessage(string name);
public override string FormatMessage(string messageString, string name);
}
public partial class MaxLengthAttribute
{
// EXISTING
// public override string FormatErrorMessage(string name);
public override string FormatMessage(string messageString, string name);
}
public partial class MinLengthAttribute
{
// EXISTING
// public override string FormatErrorMessage(string name);
public override string FormatMessage(string messageString, string name);
}
public partial class RangeAttribute
{
// EXISTING
// public override string FormatErrorMessage(string name);
public override string FormatMessage(string messageString, string name);
}
public partial class RegularExpressionAttribute
{
// EXISTING
// public override string FormatErrorMessage(string name);
public override string FormatMessage(string messageString, string name);
}
public partial class StringLengthAttribute
{
// EXISTING
// public override string FormatErrorMessage(string name);
public override string FormatMessage(string messageString, string name);
}Shape rationale
The method is a single FormatMessage(string messageString, string name) rather than a FormatErrorMessage(string template, string name) overload, so one override per attribute covers every message kind and FormatErrorMessage simply delegates to it (FormatErrorMessage(name) => FormatMessage(ErrorMessageString, name)). This keeps message customization consistent with how ErrorMessage already works (a string, or a resource type/member), the direction settled in the design discussion and reflected in the MEV localization proposal dotnet/aspnetcore#66392. Parameter names messageString and name mirror the existing ErrorMessageString property and FormatErrorMessage(string name).
Semantics
FormatMessageusesCultureInfo.CurrentCulture, matching the existingFormatErrorMessagecontract and every built-in override.- The base
FormatErrorMessage(string name)default implementation changes to delegate toFormatMessage(ErrorMessageString, name), observably identical to today. - Each built-in keeps its existing
FormatErrorMessageoverride. That method still owns validation and default-template selection (for exampleRangeAttribute.SetupConversion, andStringLengthAttribute's with/without-minimum choice), then delegates final argument projection to its newFormatMessageoverride. CustomValidationAttributeintentionally does not overrideFormatMessage. Its message is dynamic and per-invocation (_lastMessage), not a static attribute template.
Argument projection performed by each override ({0} is name in all cases):
| Attribute | Projects |
|---|---|
CompareAttribute |
{1} = OtherPropertyDisplayName ?? OtherProperty |
FileExtensionsAttribute |
{1} = ExtensionsFormatted (normalized, dot-prefixed) |
LengthAttribute |
{1} = MinimumLength, {2} = MaximumLength |
MaxLengthAttribute |
{1} = Length |
MinLengthAttribute |
{1} = Length |
RangeAttribute |
{1} = Minimum, {2} = Maximum |
RegularExpressionAttribute |
{1} = Pattern |
StringLengthAttribute |
{1} = MaximumLength, {2} = MinimumLength |
API Usage
An upper layer owns key selection and localization, then delegates attribute-specific formatting to DataAnnotations:
LocalizedString localizedMessage = localizer["RangeAttribute_Error"]; // caller owns key and lookup
string errorMessage = localizedMessage.ResourceNotFound
? validationResult.ErrorMessage!
: attribute.FormatMessage(localizedMessage.Value, name); // attribute owns its argumentsMEV's and Blazor's built-in type switches can then become one virtual call:
return attribute.FormatMessage(localizedMessage, name);For example, RangeAttribute keeps its existing placeholder mapping:
public override string FormatMessage(string messageString, string name)
{
SetupConversion();
return string.Format(
CultureInfo.CurrentCulture,
messageString,
name,
Minimum,
Maximum);
}A custom attribute likewise owns its positional arguments once, without taking a dependency on any upper-layer interface. The same override serves both the attribute's own message (via FormatErrorMessage) and an externally supplied localized template (via FormatMessage):
public sealed class DivisibleByAttribute : ValidationAttribute
{
public DivisibleByAttribute(int divisor) => Divisor = divisor;
public int Divisor { get; }
public override bool IsValid(object? value) =>
value is null || (value is int number && number % Divisor == 0);
public override string FormatMessage(string messageString, string name) =>
string.Format(CultureInfo.CurrentCulture, messageString, name, Divisor);
}Alternative Designs
- Expose
GetMessageArguments()returningobject?[]. The attribute would return its positional values and the caller would runstring.Format. This publishes a weakly typed, positional array as permanent public contract, adds an allocation, and moves culture and formatting policy out of the attribute. Some attributes also need setup before their argument values are valid. - External formatter registry (DI). Register a formatter per attribute type from the outside. This remains a useful compatibility fallback for third-party attributes that cannot be changed, but it is framework and DI specific and recreates the MVC adapter pattern rather than fixing the shared gap. It does not help built-in attributes or plain
Validator. - Return a structured key plus arguments from
ValidationResult. Validation would return raw facts (a key and untranslated arguments) and the host would localize and format later. This separates validation from presentation more completely, but it is a much broader redesign affectingValidationResult, exception messages, and every existing custom validator.
Risks
- Adding the method is additive and preserves existing
FormatErrorMessageoverrides. Existing third-party attributes with placeholders beyond{0}do not automatically learn how to format external templates, so upper layers may retain their current compatibility fallback for those attributes. - Dynamic per-validation messages, including those returned by
CustomValidationAttributeandIValidatableObject, remain outside this static attribute-template contract; callers must not apply an external template to them. FormatMessagesubstitutes arguments into the single template it is given; it does not choose among a built-in attribute's default template variants (for exampleRangeAttribute's exclusive-bound wording orStringLengthAttribute's with/without-minimum wording). That selection stays inFormatErrorMessage, and variant selection under localization is the caller's responsibility.- Unlike MEV's
IValidationMessageFormatter, the proposed method does not take an explicitCultureInfo; it follows the existing DataAnnotations contract and usesCurrentCulture. An attribute supporting both can share a culture-aware private helper behind the two entry points.
Usage in dotnet/runtime
| Location | Adoption |
|---|---|
System.ComponentModel.Annotations |
Add the base method and FormatMessage overrides on the eight built-in attributes above. |
Microsoft.Extensions.Options/gen/Emitter.cs |
Follow-up candidate: source-generated replicas of DataAnnotations attributes already override FormatErrorMessage; they can override the same method to preserve parity. |
Primary external consumers are the MVC, MEV, and Blazor implementations linked above in dotnet/aspnetcore.
Activity
dotnet-policy-service commentedon Aug 21, 2026
Tagging subscribers to this area: @dotnet/area-system-componentmodel-dataannotations
See info in area-owners.md if you want to be subscribed.
MihuBot commentedon Aug 21, 2026
I'm a bot. Here is a possible related and/or duplicate issue (I may be wrong):
bartonjs commentedon Aug 25, 2026
Video
formatto match String.Formatjustindbaur commentedon Aug 26, 2026
I know I am late to the party and this was already approved but I very recently made dotnet/aspnetcore#68705 because I have a desire to do pretty much exactly what one of the alternative designs mentions "Return a structured key plus arguments from ValidationResult" and the solution here does not really help my scenario at all. The
GetMessageArguments()alternative mentioned in the review video would somewhat help me achieve this but I really think a more holistic thinking here could be beneficial to scenarios that want to do their localization elsewhere.tarekgh commentedon Aug 26, 2026
Thanks @justindbaur and this feedback is welcome even post-approval.
I think your scenario is real but orthogonal to what this proposal intends to solve, so it does not block or change the approved shape. A couple of points:
This proposal is a formatting primitive, not a structured-error mechanism.
FormatMessage(string messageString, string name)keeps each attribute's arguments encapsulated and returns a finished, formatted string. Its purpose is to remove the duplicated per-attribute formatting switches in MVC, MEV, and Blazor and to let a caller supply an already-localized template. Your ask is the inverse: you want the raw, untranslated facts (type: "invalid_length",params: { min, max }) exposed as data so the client can localize and format. The proposal explicitly lists that as the "Return a structured key plus arguments fromValidationResult" alternative and scopes it out because it is a much broader redesign touchingValidationResult, exception messages, and every existing custom validator. Deferring it is not the same as closing it, and addingFormatMessagedoes not foreclose it.Your scenario largely does not need a DataAnnotations change. Your own proposal in dotnet/aspnetcore#68705 asks to expose
ValidationError.ValidationAttributeinMicrosoft.Extensions.Validation, then switch on the concrete attribute type and read its properties. Those properties are already public:RangeAttribute.Minimum/Maximum,StringLengthAttribute.MinimumLength/MaximumLength,RegularExpressionAttribute.Pattern,MaxLengthAttribute.Length, and so on. So the structuredparamspayload you want is achievable at the aspnetcore layer via that hook, which is exactly where you filed it, and it is not gated on this runtime primitive.The strongly-typed route is also preferable to a generic argument bag. The
GetMessageArguments()alternative was declined because a positionalobject?[]is weakly typed, allocates, and moves culture and formatting policy out of the attribute. Switching on the concrete attribute type and reading named properties (whatValidationError.ValidationAttributegives you) yields your named{ min, max }shape without committing the base class to a permanent positional argument contract.The two are complementary. Even in your client-side-localization design,
FormatMessagestill serves the human-readabledetailfallback string, while your handler emits the machine-readabletypeandparams. Approving this primitive and pursuing structured output at the aspnetcore layer can proceed independently.Add message template formatting to DataAnnotations validation attribu…
[release/11.0] Add message template formatting to DataAnnotations val…