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.)
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:
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:
@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:
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:
<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:
[HttpPost]
public ActionResult EditPersonnel(Personnel model){
if (ModelState.IsValid)
{
model.Save() }
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.
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?
- 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
.
- 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.
- Others might say, well if
Address
is null
, then assign a new instance of Address
to it before giving it to the view.
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.
- 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:
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:
@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.
- 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
.
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
.
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
:
- 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
;
- 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:
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.
- 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
.
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.