ASP.NET Web API projects using controllers have a powerful validation mechanism that provides callers with detailed messages when they have sent invalid payloads. One of the problems though, is that the messages don’t use JSON Property Names if the model properties are decorated with [JsonPropertyName] attributes. Let’s fix that.

Background

Support you have an ASP.NET API controller that allows callers to create widgets. The incoming JSON payload contains 3 values:

{
  "widgetName": "Best Cog",
  "widgetDescription": "The best cog ever",
  "available": "2025-05-01"
}

The class we bind the payload to is defined as:

public class CreateWidgetRequest
{
    [MaxLength(5)]
    [JsonPropertyName("widgetName")]
    public required string Name { get; init; }

    [MaxLength(10)]
    [JsonPropertyName("widgetDescription")]
    public required string Description { get; init; }

    [JsonPropertyName("available")]
    public DateTimeOffset? AvailableOn { get; init; }
}

By default, the JsonPropertyName values are ignored if a validation error is returned to the caller:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "detail": "Please provide the 'traceid' when contacting us.",
  "errors": {
    "Name": [
      "The field Name must be a string or array type with a maximum length of '5'."
    ],
    "Description": [
      "The field Description must be a string or array type with a maximum length of '10'."
    ]
  },
  "traceId": "00-f213f7b9bdc21f7775d5b383aef10f44-b33458f31503f7d8-00"
}

Name and Description are used, rather than widgetName and widgetDescription. The reason the JsonPropertyName attributes are used is to allow the model class to evolve and change over time without breaking the contract with the caller. The JsonPropertyName values represent that contract. We don’t want to use the backing property names because they may change over time.

The Easy Part

To make the model binder use the JsonPropertyName values, the framework includes a validation metadata provider that checks for the attributes when generating the error messages. To use this provider, change the code from:

builder.Services.AddControllers();

to:

using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;

builder.Services.AddControllers(configure =>
{
    configure.ModelMetadataDetailsProviders.Add(new SystemTextJsonValidationMetadataProvider());
})

Now the errors look correct:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.21",
  "title": "One or more validation errors occurred.",
  "status": 422,
  "instance": "/api/widgets",
  "errors": {
    "widgetName": [
      "The field widgetName must be a string or array type with a maximum length of '5'."
    ],
    "widgetDescription": [
      "The field widgetDescription must be a string or array type with a maximum length of '10'."
    ]
  },
  "traceId": "00-4af11f41cb6b0d8468e868dae13cc2df-61c5b21c6e9b6e55-00",
}

Custom Validation Errors

When you need to add a custom error message, you will typically write the messages like this:

if (_widgetService.GetWidgets().Any(x => x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)))
{
    ModelState.AddModelError(nameof(request.Name), "A different widget is already using this name.");
    return ValidationProblem(ModelState);
}

Use nameof(request.Name) as the key will result in the message using the class property name, not the JsonPropertyName value.

But we can create an extension method to do what we need:

public static void AddModelError<TModel>(
    this ModelStateDictionary modelState,
    string propertyName,
    string errorMessage)
{
    var property = typeof(TModel).GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
    var modelKey = property?.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name ?? propertyName;

    modelState.AddModelError(modelKey, errorMessage);
}

Using this method is very similar to the original:

// Original
ModelState.AddModelError(nameof(request.Name), "A different widget is already using this name.");

// New extension method
ModelState.AddModelError<CreateWidgetRequest>(nameof(request.Name), "A different widget is already using this name.");

This is an easy change and makes custom validation messages behave like the built-in validation error messages. Yay!