Click here to Skip to main content
15,884,298 members
Articles / Web Development / ASP.NET

ASP.NET MVC Handling Null Models

Rate me:
Please Sign up or sign in to vote.
4.98/5 (17 votes)
12 Jun 2013CPOL11 min read 192.3K   28   8
A proposed solution to handling null models in an ASP.NET MVC view.

Introduction

Have you ever had a view model composed of several domain models. For example, a Personnel view model that has a Person object and an Address object. You bind this to a view that is used for admin purposes like changing the person's name or address. Well what happens if one of these domain models can be null. It is possible that a person doesn't have an address for whatever reason, or you just need to enter their name in the system without the address. But at the same time, you have to show the view of the address because the admin user could enter the address at any time. Having models that can possibly be null creates some complications in ASP.NET MVC.

I found myself running into a specific situation, Perhaps others have too? I'll assume you are pretty familiar with ASP.NET MVC. So let me describe the scenario. I have an admin screen where I am editing the information for Personnel. There are input elements for first name, last name, street address, etc. So let's say I have 2 domain models as such. (The code shown is way simplified.)

C#
public class Person
{
    public int ID {get;set;}
    public string Name{get;set;}
    public int? AddressID {get;set;}
}
public class Address
{
    public int ID{get;set;}
    public string Street {get;set;}
}

and let's say I have a View Model as such:

C#
public class Personnel
{
    public Person Person{get;set;}
    public Address Address{get;set;}
}   

Realize that there is a one to one relationship between Person and Address, and that Person.AddressId is nullable. I won't show the code but, let's say there is some mechanism (entity framework or whatever) to retrieve an instance of a Personnel object by ID. The key thing to realize is that it is possible for the Address property of Personnel to be null because not all personnel had their address information entered and so there will be no record in the database for it. Let's also assume that if Address is not null, then whatever mechanism you are using for persistence will insert a record in the database if ID =0, else it will do an update. Here's where it gets weird. The UI or View still needs to still show the input elements for the address model because at any time the admin user can then just put in the person's address OR they can leave it blank if they don't intend to provide address information. So Address may be null but yet the UI input elements still need to be present in the page. I have a partial view that is used for the Address Type which looks something like this:

C#
@Html.HiddenFor(m => m.ID)
@Html.EditorFor(m => m.Street)  

Assume I put all this into a controller and view and request the URL for the personnel admin page for an employee that had no address. It might look something like this:

Image 1

Problem

OK, so what's the problem with this? If I view the source of the page and look at the markup in the Address section, I see this:

HTML
<input value name="Address.ID" type="hidden"> 

The value of the hidden input is null or really a blank string. This is how it should be since Address was null to begin with and there is nothing to put into value.
So, perhaps the admin user changes the first name to "Stan" and doesn't do anything to address and hits save. Assume that save submits the form and it posts to a controller that looks like this:

C#
[HttpPost]
public ActionResult EditPersonnel(Personnel model){
    if (ModelState.IsValid)
	{ 
        model.Save() // or whatever
    }
    return View(model);
}

We finally come to where the problem is. What is wrong with the above code? model.Save() will never be called and our information will never be persisted to the database, why? Because ModelState.IsValid is always going to be false. What's going on? The problem is occurring because of the way the MVC default model binder works. When the model binder tries to deserialize an object from the posted data of the request, it has to be able to find a valid value for any Value Type property, such as int, short, bool, ... etc. No exceptions. In this case, its Address.ID. Unfortunately the request data has a value of "" (blank string) for Address.ID because that is what the value of the hidden input element was. You can see this if you inspect the network request as I did in Chrome.

Image 2

MVC doesn't put the default value of a type in the value attribute of an input field. Address.ID is an integer, so if the Address property is null, you might think MVC HtmlHelper.HiddenFor would put the default value of integer in it, that being 0, but it doesn't. So, because the model binder cannot transform "" into 0 for the Address.ID it adds an entry into the ModelState's errors collection and IsValid will always be false and the personnel model will never save. That is of course, only if you want to use validation. If you don't use validation, you won't run into this problem, but who doesn't use validation? I assume you want to use the system that Microsoft has built in. Using data annotations and having the DefaultModelBinder automatically handle everything is very convenient.

Solutions?

  1. The most direct way to circumvent the problem is to alter the type of Address.ID to be Nullable<int>. If a property on a model is nullable, then it doesn't need to be bound and a blank string can be converted into a null integer and the error will not be registered. I don't choose this technique because it is distorting the domain model. ID is the primary key of Address in the database so it can't be null. But maybe this is a interesting solution. I mean if you create a new address, the ID is going to be 0 until it is saved to the database and populated with the new ID. Why couldn't it just be null instead of 0.
  2. Some might say the UI shouldn't be like this. If address can be null then the input fields for address should not exist until the user clicks an "add new address" button or something to that affect. This is perhaps a good way to do it, but the relationship of Person to Address is one to one, in my opinion it would be awkward to have an "Add New Address" button when you can only add one new address ever. Also, the requirements document might insist that it is displayed the way we have it.
  3. Others might say, well if Address is null, then assign a new instance of Address to it before giving it to the view.
    C#
    if(p.Address == null){p.Address = new Address();}   

    I guess this is ok, but it is not considering the fact that the admin user might not enter anything in the address fields which indicates that the Person does not have an address. If the user saves, yes all the data from the request will be valid and the model binder won't create an error, but then a blank Address record will be inserted into the database. Perhaps this would be acceptable in some situations.

  4. What if we prevented the initial condition that is causing the problem in the first place, that being a empty string cannot be converted into a integer for Address.ID. A way to do this is to not have an input element for Address.ID. If the Request does not contain an entry for Address.ID then the binder will not try to convert the value into an integer. So we could create a HtmlHelper extension method that mimics what HiddenFor does. I want to be able to check if the model is null and if so, not create any markup. It might look something like this:
    C#
     public static MvcHtmlString HiddenForDefault<TModel, 
       TProperty>(this HtmlHelper<TModel> htmlHelper, 
       Expression<Func<TModel, TProperty>> expression)
    {
        ModelMetadata metadata = ModelMetadata.FromLambdaExpression
        <TModel, TProperty>(expression, htmlHelper.ViewData);
       
        if (!metadata.ModelType.IsValueType)
        {
            throw new Exception
            ("Don't use this for reference types which don't need a value");
        }
        if (metadata.Model == null)
        {
            return new MvcHtmlString("") ;
        }
        else
            return htmlHelper.HiddenFor(expression);
    } 

    The we can change our address view to be like this:

    C#
    @Html.HiddenForDefault(m => m.ID)
    

    Now, if Address is null, no markup will be created for the input element for Address.ID. A thing to note is that the DefaultModelBinder will still create an instance of Address on the Personnel object because the Street input element is there. When binding is done, if it sees any Request key named "Address.whatever" it will create a new Address object and put it into Personnel.Address. If no key is found, then Personnel.Address will be null. So now, because the binder is no longer trying to put an empty string into the integer Address.ID, no errors are registered and validation passes. Great!
    **When I say Request key, I mean the key value in the collection of where the DefaultModelBinder looks for incoming information: Request.Form, the URL, Request.QueryString, etc.

  5. So now we are able to get passed the validation but there is still one problem. The binder is creating a instance of the Address object, which then means when saving occurs, there will be a blank record inserted into the database. This is like the issue #3 caused. Can anything be done about this? Yes. In ASP.NET MVC, you are allowed to create your own custom model binders. We can create one that is aware of the fact that Address is allowed to be null and will adjust accordingly. To create a custom binder, I create a class that derives from System.Web.Mvc.DefaultModelBinder.
    C#
    public class PersonnelModelBinder:DefaultModelBinder
    {
         public override object BindModel
         (ControllerContext controllerContext, ModelBindingContext bindingContext)
         {
               Personnel p =  base.BindModel(controllerContext, bindingContext) as Personnel;
          }
    } 

    Now we need to let the MVC system know that any time it is trying to deserialize a Personnel type, it should use the PersonnelModelBinder. We can do this by putting the following code in the website's global.asax Application_Start.

    C#
    System.Web.Mvc.ModelBinders.Binders.Add(typeof(Personnel), new PersonnelModelBinder()); 

    In PersonnelModelBinder I have overridden the BindModel method which is what is called by the MVC system when you want to deserialize an object from the request data. We still want to use the base classes method by calling base.BindModel. This is because DefaultModelBinder does all the complex work of pulling data out of the request and using reflection to decide how to create the object and putting the appropriate values into the properties of said object. We definitely don't want to have to write that code ourselves.

    So after the above code executes, we now have a deserialized Personnel model in variable p. Remember that even though there is no Address.ID in the request data, the binder still successfully creates an Address object and ID will be 0. From the above code, p.Address.ID will be 0. So now we need to determine our logic of how to decide if the Address object should remain or be set to null:

    1. If Address.ID == 0 and all the other fields of Address are empty/null/0, then the user did not intend to create an address. So we must set p.Address to null;
    2. If Address.ID == 0 but one of the fields has a value, then the user intended to create an address so we must not set p.Address to null;

    Remember, we are under the assumption that if Address is null, whatever technique for persistence we are using will then ignore Address and not save it, else if not null it will save it.
    So changing the PersonnelModelBinder to use this logic might look like this:

    C#
    public class Peronnel:BaseModelBinder
    {
        public override object BindModel
        (ControllerContext controllerContext, ModelBindingContext bindingContext)
        {
    
            Personnel p =  base.BindModel(controllerContext, bindingContext) as Personnel;
            if(
                p.Address.ID == 0 &&
                p.Address.Street == ""
            )
            {
                p.Address = null;
            }
            return p;          
        }       
    } 

    So after we implement this logic, we will now have the behavior we want. If all properties of the Address object are empty (zeroes, blank strings, nulls), that means the user didn't want to create an address so we set Address to null so a blank record doesn't get saved. If any of the properties of Address has a value, then we do nothing and the persistence mechanism will save it.

  6. So with solutions #4 and #5, you have to use them together in order for it to work. Perhaps one might not like this approach. You have to know to use HiddenForDefault and implement it anywhere that a Model could be null and then you also have to have a custom model binder for the model. Perhaps it would be better if all this stuff was handled in one place. Here's how we could alter the PersonnelModelBinder to handle everything so that we don't have to use HiddenForDefault.
    C#
    public override object BindModel(
       ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
    
        Personnel p =  base.BindModel(controllerContext, bindingContext) as Personnel;
    
        if(
            p.Address.ID == 0 &&
            p.Address.Street == ""
        )
        {
            p.Address = null;
            foreach (var k in bindingContext.ModelState.Keys)
            {
                if (k.Contains("Address."))
                {
                    bindingContext.ModelState[k].Errors.Clear();
                }
            } 
        }
        else if(p.Address.AddressID == 0)
        {
            bindingContext.ModelState["Address.AddressID"].Errors.Clear();
        }
        return p;
      
    } 

    So now let's assume that we no longer use HiddenForDefault and only use the PersonnelModelBinder. So what is this Binder doing? First, it does that same check as before to determine if the User did not intend to create an address and as before it will set Address to null. But now it needs to accommodate for the fact that we will now have errors in the ModelState because we aren't using HiddenForDefault. This is done simply by clearing out the error collection in any ModelState whose key contains the text "Address.". This is the prefix the applies to all properties coming from the Address object. Another conditional was added to accommodate when the user has entered information into the inputs with the intention of creating a new address. Address Id doesn't get created by the user and so will still be a blank string and so will still create an error. We just need to clear that one specific error out, not all of them, because the user could have put in bad values such as "?????+++****" for the street which is invalid. And of course, we don't set Address to null because we want it to be there to save.

So these are the solutions I went through to deal with the scenario I described. I think #6 is the best solution to use since it handles the problems all in one place, but you may like to use one of the other solutions.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer eLeadCrm
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
Questionvery confusing Pin
Member 10419919-Jul-13 18:38
Member 10419919-Jul-13 18:38 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA13-Jul-13 20:55
professionalȘtefan-Mihai MOGA13-Jul-13 20:55 
GeneralMy vote of 4 Pin
Vinh Phan13-Jun-13 13:43
Vinh Phan13-Jun-13 13:43 
GeneralThanks for sharing, I go for 3 Pin
snoopy00111-Jun-13 2:21
snoopy00111-Jun-13 2:21 
GeneralMy vote of 5 Pin
Atal Upadhyay10-Jun-13 20:33
Atal Upadhyay10-Jun-13 20:33 
very useful
QuestionDuplicate entity in viewmodel Pin
Atal Upadhyay10-Jun-13 20:04
Atal Upadhyay10-Jun-13 20:04 
QuestionVery Cool Techniques Pin
asok.1421510-Jun-13 9:10
asok.1421510-Jun-13 9:10 
SuggestionData Layer Pin
Jason Christian10-Jun-13 7:54
Jason Christian10-Jun-13 7:54 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.