Headline: A very simple change to the DefaultModelBinder will allow you to use default validation capabilities in Microsoft’s MVC (Model-View-Controller) Framework far more effectively.
The Problem – Please show my model exceptions to the user!
As Steven Sanderson stated in his book, you want to have the validation for your model in an MVC application in the Model, not in the View. However, in MVC that seems to be hard. If you throw an exception in your model the Exception gets set on the ModelError, but not the ErrorMessage (a string) which is what’s used to communicate back to the user by default.
So what happens in the DefaultModelBinder is that when you throw an exception in your model class (or ViewModel if you’re using that pattern) the ErrorMessage is string.Empty so your MVC application has a “blank stare” appearance for the user. While there have been several suggestions for working around this it still felt like I was having to do too much work.
A Solution
Override the SetProperty method on the DefaultModelBinder and your set!
public class RuleModelBinder : DefaultModelBinder
{
protected override void SetProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, System.ComponentModel.PropertyDescriptor propertyDescriptor, object value)
{
if (!propertyDescriptor.IsReadOnly)
{
try
{
propertyDescriptor.SetValue(bindingContext.Model, value);
}
catch (Exception exception)
{
string key = CreateSubPropertyName(bindingContext.ModelName, propertyDescriptor.Name);
//bindingContext.ModelState.AddModelError(key, exception); // Why, oh why, doesn’t this set BOTH the message AND the excpection?
bindingContext.ModelState[key].Errors.Add(new
ModelError(exception, exception.Message));
}
}
}
}
So what I did above is create my own class called “RuleModelBinder” which inherits from the MVC DefaultModelBinder. I then overrode the SetProperty method which is where the Error gets set. Unfortunately, the AddModelError method only has two overrides 1) AddModelError(string key, string errorMessage) and 2) AddModelError(string key, Exception exception). The use of AddModelError(key, exception) leaves the ErrorMessage property of the ModelError empty.
In order for this to work you need one more line that Steven points out… In your Global.asax.cs file add one line:
protected
void Application_Start()
{
RegisterRoutes(RouteTable.Routes);
ModelBinders.Binders.DefaultBinder = new
RuleModelBinder();
}
A Better Solution (Update)
Ok… That was nice, but that doesn’t catch the “Microsoft” exceptions. Here’s a simple example. The Model has two numbers each of which must be between 0 and 1. If I input “a” and “-1” for those fields with the above solution I would see this:
As you can see, there is not error message for the “a” value, just my exception for the range.
But if I remove the SetProperty method and add a different override shown here:
protected
override
void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor)
{
// Bind the way we normally would.
base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
// Now get the key to the property
string key = propertyDescriptor.Name;
if (bindingContext.ModelState.ContainsKey(key))
{
// Did this property have an error?
if (bindingContext.ModelState[key].Errors.Count > 0)
{
// If this error has no ErrorMessage, but does have an Exception, then replace
// the Error with a new ModelError that has both.
if (string.IsNullOrEmpty(bindingContext.ModelState[key].Errors[0].ErrorMessage)
&& !ReferenceEquals(bindingContext.ModelState[key].Errors[0].Exception, null))
{
// Get the exception…
Exception originalException = bindingContext.ModelState[key].Errors[0].Exception;
// For Microsoft errors, the inner exception is usually more user friendly… if there is one.
Exception exception = (ReferenceEquals(originalException, null)) : orginalException : orginalException.InnerException;
// Now clear the Errors collection.
bindingContext.ModelState[key].Errors.Clear();
// And now add an error message that is meaningful.
bindingContext.ModelState[key].Errors.Add(new
ModelError(exception, exception.Message));
}
}
}
}
Then these are the results:
I’ve not tried multi-lingual support yet.
It would sure be nice if the MVC team would replace all the “AddModelError(key, exception)” lines in the DefaultModelBinder with one that would set both the ErrorMessage and the Exception.
Benefits
- Maintain your business rules in the model – The first and most obvious is what Steven pointed out – do your validation in the model to maintain separation of concerns.
- Leverage the default validation in MVC – The next most obvious is that when you create a new View for a controller that is strongly typed, MVC provides nice validation via the Html.ValidationSummary and Html.ValidationMessage methods. This does not require you to change that.
- You control the message in the model – Finally, if you want to have multi-language support (which isn’t easy if you put your model in a separate .dll, but possible) then you don’t have to do the work twice.
My thanks to Steven Sanderson, the MVC team at Microsoft, and the Redgate.NET Reflector (originally developed by Lutz Roeder) for making this post possible… and my life easier.
Enjoy – Karl
Leave a Reply