0

I’m building an ASP.NET Core MVC application with a view model that contains a nullable enum, decimal and other int properties.

Example:

public class UserViewModel
{
    public decimal? Amount { get; set; }
}

I was hoping to use Fluent Validation due to some complex validation scenarios coming up soon.

using FluentValidation;

public class UserViewModelValidator : AbstractValidator<UserViewModel>
{
    public UserViewModelValidator()
    {
        RuleFor(x => x.Amount)
            .NotNull().WithMessage("Please enter an amount.")
            .GreaterThan(0).WithMessage("Amount must be greater than zero.");
    }
}

I notice that in the amount input if the input field receives “abc”, it ends up adding a default error message to the ModelState.

Is there a recommended way to handle the validation all in my Fluent Validator or at least give each model property the ability to define the error message at property level when the binding was unsuccessful?

I know I can use ModelBindingMessageProvider to overwrite the messages via assessors, I was however hoping to have something more customisable at a property level.

I appreciate if any additional info is required so please feel free to ask.

2 Answers 2

0

You can customize the default model binding error globally

builder.Services.AddControllersWithViews()
    .AddMvcOptions(options =>
    {
        options.ModelBindingMessageProvider
            .SetValueMustBeANumberAccessor(value =>
                $"Please enter a valid number.");
    });
Sign up to request clarification or add additional context in comments.

3 Comments

Hey, thanks for your suggestion, I am aware of this way, I was wondering if there was anything with more flexibility on view models the same way I can pass an error message directly to a data annotation attribute?
You could do it with a custom model binder for each property. But it’s a bit more work.
Yeah I had debated whether to use the custom model binders, I was just worried if might have been considered hacky. I’ve seen some suggestions to bind to strings, validate with Fluent Validation and then map to my business model
0

What you ask for has several solutions, none of them is perfect i guess.

The most simple is to use SetValueMustBeANumberAccessor and I think it's the cleanest.

However if you need more flexibility, you can build around fluent validation and ASP.NET validation pipeline.

First of all, i want to mention that FluentValidation.AspNetCore package is deprecated and building around it might be not best decision as in terms of maintainability.

Having said all that, here's what you can do also to make your approach more centralized around Fluent Validation.

The most important thing here to understand is that ASP.NET has it's own validation built in. If the content fails to be parsed to the model, ASP.NET short-circuits the request, meaning it does not invoke neither the action (method for endpoint) neither none of its filters. So first step is to disable this behavior

builder.Services.Configure<ApiBehaviorOptions>(options =>
{
    options.SuppressModelStateInvalidFilter = true;
});

This might be breaking change, as now it won't short circuit. We get one advantage and one disatvantage:

  • now we can define filter and handle model binding errors manually (so could leverage fluent validation in the process)
  • but now it also reaches the method itself, where we need to check for ModelState.IsValid i.e. if (!ModelState.IsValid) return BadRequest(ModelState);

So, now we can use following logic in filter that will be executed before the action:

  • check if model state is valid
  • if not, check if there is "validatable" model to be checked, if so, validate the object
  • if validation initially failed, we will have null instead of model, so we create empty model and validate it
  • now we compare default validation and replace any errors that are coming out of our Fluent Validation.

Below is implementation of above logic and it contains inline comments for clarity:

public class CustomModelBindingErrorFilter : IActionFilter
{
    private readonly IServiceProvider _serviceProvider;

    public CustomModelBindingErrorFilter(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public void OnActionExecuting(ActionExecutingContext context)
    {
        // Only proceed if ModelState is invalid (binding failed)
        if (context.ModelState.IsValid)
            return;

        // Go through each action argument
        foreach (var kvp in context.ActionDescriptor.Parameters)
        {
            var paramName = kvp.Name;
            var paramType = kvp.ParameterType;

            // Try to get the actual bound object (might be null)
            context.ActionArguments.TryGetValue(paramName, out var modelInstance);

            // Try to resolve validator dynamically from DI
            var validatorType = typeof(IValidator<>).MakeGenericType(paramType);
            var validator = _serviceProvider.GetService(validatorType) as IValidator;

            if (validator == null)
                continue; // No validator registered, skip

            // Create an empty instance if binding failed (null)
            if (modelInstance == null)
                modelInstance = Activator.CreateInstance(paramType);

            // Run FluentValidation manually
            var validationContext = new ValidationContext<object>(modelInstance);
            ValidationResult result = validator.Validate(validationContext);

            // For each existing property with an error, replace its messages
            foreach (var stateEntry in context.ModelState)
            {
                string propertyName = stateEntry.Key;
                if (stateEntry.Value?.Errors?.Count > 0)
                {
                    // See if validator has a rule for this property
                    var failure = result.Errors.FirstOrDefault(e =>
                        string.Equals($"$.{e.PropertyName}", propertyName, StringComparison.OrdinalIgnoreCase));

                    if (failure != null)
                    {
                        // Replace binding errors with FluentValidation message
                        stateEntry.Value.Errors.Clear();
                        stateEntry.Value.Errors.Add(failure.ErrorMessage);
                    }
                }
            }
        }
    }

    public void OnActionExecuted(ActionExecutedContext context) { }
}

And of course, to use that filter you need to register it:

builder.Services.AddControllers(options =>
{
    options.Filters.Add<CustomModelBindingErrorFilter>();
});

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.