Skip to content

[API Proposal] Message template formatting support for DataAnnotations validation attributes #132605

Closed

Description

@ViveliDuCh

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

  • FormatMessage uses CultureInfo.CurrentCulture, matching the existing FormatErrorMessage contract and every built-in override.
  • The base FormatErrorMessage(string name) default implementation changes to delegate to FormatMessage(ErrorMessageString, name), observably identical to today.
  • Each built-in keeps its existing FormatErrorMessage override. That method still owns validation and default-template selection (for example RangeAttribute.SetupConversion, and StringLengthAttribute's with/without-minimum choice), then delegates final argument projection to its new FormatMessage override.
  • CustomValidationAttribute intentionally does not override FormatMessage. 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 arguments

MEV'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() returning object?[]. The attribute would return its positional values and the caller would run string.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 affecting ValidationResult, exception messages, and every existing custom validator.

Risks

  • Adding the method is additive and preserves existing FormatErrorMessage overrides. 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 CustomValidationAttribute and IValidatableObject, remain outside this static attribute-template contract; callers must not apply an external template to them.
  • FormatMessage substitutes arguments into the single template it is given; it does not choose among a built-in attribute's default template variants (for example RangeAttribute's exclusive-bound wording or StringLengthAttribute's with/without-minimum wording). That selection stays in FormatErrorMessage, and variant selection under localization is the caller's responsibility.
  • Unlike MEV's IValidationMessageFormatter, the proposed method does not take an explicit CultureInfo; it follows the existing DataAnnotations contract and uses CurrentCulture. 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

  1. dotnet-policy-service commented on Aug 21, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-system-componentmodel-dataannotations
    See info in area-owners.md if you want to be subscribed.

  2. MihuBot commented on Aug 21, 2026

    @MihuBot

    I'm a bot. Here is a possible related and/or duplicate issue (I may be wrong):

  3. self-assigned this
    on Aug 21, 2026
  4. added
    blockingMarks issues that we want to fast track in order to unblock other important work
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    and removed
    untriagedNew issue has not been triaged by the area owner
    on Aug 21, 2026
  5. bartonjs commented on Aug 25, 2026

    @bartonjs
    Member

    Video

    • Added the CompositeFormat syntax marker, renamed the first parameter to format to match String.Format
    namespace System.ComponentModel.DataAnnotations;
    
    public abstract partial class ValidationAttribute
    {
        public virtual string FormatMessage([StringSyntax("CompositeFormat")] string format, string name);
    }
  6. added
    api-approvedAPI was approved in API review, it can be implemented
    and removed
    blockingMarks issues that we want to fast track in order to unblock other important work
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on Aug 25, 2026
  7. added this to the 11.0.0 milestone on Aug 25, 2026
  8. justindbaur commented on Aug 26, 2026

    @justindbaur

    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.

  9. tarekgh commented on Aug 26, 2026

    @tarekgh
    Member

    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 from ValidationResult" alternative and scopes it out because it is a much broader redesign touching ValidationResult, exception messages, and every existing custom validator. Deferring it is not the same as closing it, and adding FormatMessage does not foreclose it.

    Your scenario largely does not need a DataAnnotations change. Your own proposal in dotnet/aspnetcore#68705 asks to expose ValidationError.ValidationAttribute in Microsoft.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 structured params payload 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 positional object?[] is weakly typed, allocates, and moves culture and formatting policy out of the attribute. Switching on the concrete attribute type and reading named properties (what ValidationError.ValidationAttribute gives 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, FormatMessage still serves the human-readable detail fallback string, while your handler emits the machine-readable type and params. Approving this primitive and pursuing structured output at the aspnetcore layer can proceed independently.

  10. added a commit that references this issue on Aug 27, 2026
    c365a0b
  11. locked and limited conversation to collaborators on Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions