Be Aware of DefaultModelBinder Conventions
DefaultModelBinder
is an essential piece of ASP.NET MVC framework which makes writing strongly typed actions really simple. In spite of its strengths (or maybe because of them) it can still introduce hard to solve problems in your code. Take a look at the following example, a simplification of the problem I was confronted with today:
public class DocumentVersion
{
public int Id { get; set; }
public int Version { get; set; }
public string Name { get; set; }
}
public class DocumentController : Controller
{
public ActionResult New()
{
return View();
}
public ActionResult Save(DocumentVersion version)
{
if (ModelState.IsValid)
{
// save data
return View("Confirm");
}
return View("New");
}
}
Assuming all DocumentVersion
properties are submitted and valid Save
action should return Confirm
view, right? Wrong! Try it out and you'll get a validation error on Version property. Taking a closer look it turns out ModelState["Version"].Errors[0].Exception
contains an InvalidOperationException
:
The parameter conversion from type 'System.String' to type 'MvcApplication1.Models.DocumentVersion' failed because no type converter can convert between these types.
Of course there's no String
to DocumentVersion
converter. Though, Version
property is an int
. Why does it want to convert it to a DocumentVersion
?
I soon started running out of ideas and fortunately enough I quickly decided to enable .NET Framework source stepping. A few moments later I reached the following piece of code in DefaultModelBinder
and suddenly it became obvious what was happening:
if (!String.IsNullOrEmpty(bindingContext.ModelName)
&& !bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName)) {
// We couldn't find any entry that began with the prefix.
// If this is the top-level element, fall back to the empty prefix.
if (bindingContext.FallbackToEmptyPrefix) {
bindingContext = new ModelBindingContext() {
ModelMetadata = bindingContext.ModelMetadata,
ModelState = bindingContext.ModelState,
PropertyFilter = bindingContext.PropertyFilter,
ValueProvider = bindingContext.ValueProvider
};
performedFallback = true;
}
else {
return null;
}
}
// Simple model = int, string, etc.; determined by calling
// TypeConverter.CanConvertFrom(typeof(string))
// or by seeing if a value in the request exactly matches
// the name of the model we're binding.
// Complex type = everything else.
if (!performedFallback) {
bool performRequestValidation =
ShouldPerformRequestValidation(controllerContext, bindingContext);
ValueProviderResult vpResult =
bindingContext.UnvalidatedValueProvider
.GetValue(bindingContext.ModelName,
skipValidation: !performRequestValidation);
if (vpResult != null) {
return BindSimpleModel(controllerContext, bindingContext, vpResult);
}
}
Notice the call to bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName)
at the top and read the comment above the bottom block of the code. It turns out that in my sample ModelName
was version
just like one of the DocumentVersion
properties therefore DefaultModelBinder
decided to use simple model binding which failed because of a missing converter as it was also clearly stated in the exception. You might be wondering where ModelName
came from. It's the name of the action method parameter. Fixing the code was simple now – rename the parameter and the code starts working as expected:
public ActionResult Save(DocumentVersion documentVersion)
{
if (ModelState.IsValid)
{
// save data
return View("Confirm");
}
return View("New");
}
Lesson of the day? Be aware of conventions and make sure parameter names don't match any of the property names if you are using complex models.