OdeToCode IC Logo

Iterating on an ASP.NET MVC Model Binder

Tuesday, May 5, 2009

After my last post on model binding tips I’ve had a number of questions about the nuances of model binding. Let’s work through a sample.

Imagine you have a Recipe class that can hold all the information you need to make Love Mussels, and now you've decided to build a model binder to bind and validate recipes. 

Iteration 1

Let’s start with the naive approach. This code has a number of problems.

public class RecipeModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var form = controllerContext.HttpContext.Request.Form;

        var recipe = new Recipe();            
        recipe.Name = form["Name"];
        // ... and so on for all properties

        if (String.IsNullOrEmpty(recipe.Name))
        {
            bindingContext.ModelState.AddModelError("Name", "...");
        }

        return recipe;
    }
}

Problem

The code works directly off the HttpContext.Request.Form.

There are a couple difficulties working with the Form collection directly. One problem is how your tests will require more setup to create an HttpContext, Request, and Form objects.

The second problem is related to culture. Values you need from the Form collection are culture sensitive because the user will type a date and time value into their browser using a local convention. However, the URL is another place you might need to check when binding values (the query string and routing data in general), and these values are culture invariant.

Instead of worrying about all these details, it’s better to use the ValueProvider given to us by the incoming binding context. The value provider is easy to populate in a unit test, and takes care of culture sensitive conversions.

Iteration 2

We’ll add a GetValue method to help fetch values from the ValueProvider. At runtime the MVC framework populates the provider with values it finds in the request’s form, route, and query string collections.

public class RecipeModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var recipe = new Recipe();
        recipe.Name = GetValue<string>(bindingContext, "Name");
        // ... and so on for all properties

        if (String.IsNullOrEmpty(recipe.Name))
        {
            bindingContext.ModelState.AddModelError("Name", "...");
        }

        return recipe;
    }

    private T GetValue<T>(ModelBindingContext bindingContext, string key) 
    {
        ValueProviderResult valueResult;
        bindingContext.ValueProvider.TryGetValue(key, out valueResult);            
        return (T)valueResult.ConvertTo(typeof(T));
    }  
}

Problem:

If you use any HTML Helpers (like Html.TextBox), you’ll see null reference exceptions when validation errors are present.

One of the side-effects of model binding is that binding the model should put model values into ModelState. When an HTML helper sees there is a ModelState error for “Name”, it assumes it will also find the “attempted value” that the user entered. The helper uses attempted values to repopulate inputs and allow the user to fix any errors.

Iteration 3

The only change is to set the model value inside GetValue.

private T GetValue<T>(ModelBindingContext bindingContext, string key)
{
    ValueProviderResult valueResult;
    bindingContext.ValueProvider.TryGetValue(key, out valueResult);
    bindingContext.ModelState.SetModelValue(key, valueResult);       
    return (T)valueResult.ConvertTo(typeof(T));
}  

Problem:

The model binder we’ve written so far will work with controller actions like the following:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create(Recipe newRecipe)
{
    // ...
    return View(newRecipe);
}

But it won’t work in this scenario:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Create(FormCollection formCollection)
{
    var recipe = RecipeFactory.Create();
    TryUpdateModel(recipe);
    if (ModelState.IsValid)
    {
        // save it ...
    }
    return View(recipe);
}

The recipe in the controller action will never see any changes when calling TryUpdateModel, because the model binder is creating and binding data into its own recipe object. Perhaps you never use UpdateModel or TryUpdateModel, but if you expect your model binder to work in any possible situation, you need to make sure the model binder works when someone else creates the model.

Iteration 4

The only change is to check bindingContext.Model to see if we already have a model. Only when this property is null will we go to the trouble of creating a new model. 

public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
    var form = controllerContext.HttpContext.Request.Form;

    var recipe = (Recipe)(bindingContext.Model ?? new Recipe());
    recipe.Name = GetValue<string>(bindingContext, "Name");
    
    // ... 

    return recipe;
}

Problem:

Congratulations! We just re-implemented the behavior of the DefaultModelBinder - only our model binder is stupid and only works with Recipes.

If all we need is validation for a specific type of model, we can derive from the built-in binder and override OnModelUpdated or OnPropertyValidating and provide our custom logic.

Iteration 5

OnModelUpdated is easy to work with, so what follows is the entire listing for our custom model binder.

public class RecipeModelBinder : DefaultModelBinder 
{
    protected override void OnModelUpdated(ControllerContext controllerContext,
                                           ModelBindingContext bindingContext)
    {
        var recipe = bindingContext.Model as Recipe;
        if (String.IsNullOrEmpty(recipe.Name))
        {
            bindingContext.ModelState.AddModelError("Name", "...");
        }
    }
}

It turns out we didn’t need any code from those first four iterations, but I hope you find them useful because they demonstrate some common problems I’m seeing in custom model binders. Of course we could take this example even further and eliminate the magic strings, but we’ll leave the work for another day.

In software development – every iteration is a learning opportunity!