Author avatar

A.J. Saulsberry

ASP.NET MVC - Getting Default Data Binding Right for Hierarchical Views

A.J. Saulsberry

  • Apr 18, 2019
  • 29 Min read
  • Apr 18, 2019
  • 29 Min read
Web Development


Developers usually stumble with default binding when handling the HttpPost action. For example: if you have view, view model, controller actions, and data all wired-up and it looks like your form should be working, but your model is empty or partially empty when it hits the controller after you press "Save", you probably need to make some adjustments to the view to get the Razor engine to compose the HTML properly. Getting the code for the client side right is essential, and it fails silently when it's wrong.

If you just need help with that, jump to the section on Saving Data.

If you feel you would benefit from a more complete case study, read on.


Many developers know that they can create forms on web pages with a minimum of code using ASP.NET model binding. Visual Studio's default MVC view templates will even create a standard list, create, edit, and delete views without any additional programming.

But the power of default model binding extends beyond the flat data model of a simple input form or list of records. Using a few straightforward coding techniques, developers can use ASP.NET to create forms and collect data for hierarchical entity relationships. In many applications, this can make the difference between leveraging the rapid development capabilities of ASP.NET MVC and strapping on the additional infrastructure and complexity of a client-side framework like Angular or React.

This guide will present an example of using ASP.NET MVC model binding to present and collect hierarchical form data in a hierarchical structure.

Skill Levels

It will be helpful to have an understanding of these topics at the indicated skill level:

TechnologySkill Level
Entity FrameworkBeginner


This guide will present an example of using ASP.NET MVC model binding to present and collect hierarchical form data in a hierarchical structure.

The example project and the code presented in this guide are based on the .NET Framework. Implementation details for .NET Core and .NET Standard will be covered in a different guide.


  1. We'll begin with an overview of the case study entities and the principal views of the example solution used to prepare this guide.

  2. Then, we'll see how to create a view model incorporating member fields of various primitive types and incorporating a field that is a collection of an object type.

  3. Next, we'll look at the code required to present information to the end user.

  4. We'll conclude with a close look at how to ensure that Razor code creates the correct HTML and we'll take a look how to use HtmlHelpers and CSS to apply formatting to the form fields created by the view.

Case Study

The code and screenshots shown in this guide match the code and view layouts an example Visual Studio project. The solution can be forked or downloaded from a GitHub repository:

The sample solution implements the following:

  • A multi-project solution with separate projects for
    • Web applications
    • Entities
    • Data layers (context and repositories)
  • The Model-View ViewModel (MVVM) design pattern
  • The repository design pattern
  • The Entity Framework ORM with code-first development

Using the example solution you can follow along with each section below and experiment on your own.


You should have:

  • A working knowledge of ASP.NET MVC
  • An understanding of the Model View ViewModel (MVVM) design pattern
  • Visual Studio ready to go

BlipBinding Case Study Solution

The case study implemented by the BlipBinding solution is a simple application for maintaining information about customers, their orders, and the items in their orders. The permanent data store for the application is a SQL Server database. The tables and their relationships are shown below:

BlipBinding entity relationship diagram

BlipBinding database entity-relationship diagram

Entities and Relationships

Note that the many-to-many relationship between Orders and Items is implemented through the use of a merge table with payload: in addition to maintaining the relationship between Orders and Items, the OrderItems table also contains information about the items included in an order, the price at which they were sold and the quantity which were sold.

Obviously, this isn't a complete order processing system; it's just meant to provide an example of hierarchical relationships in a familiar form.

The database is created and maintained using Entity Framework code-first design. Each table and it's relationship to other tables is defined by a class in the Blip.Entities project of the BlipBinding solution. Let's look at the Customer and Order entities:


1using System;
2using System.Collections.Generic;
3using System.ComponentModel.DataAnnotations;
4using System.ComponentModel.DataAnnotations.Schema;
5using Blip.Entities.Geographies;
6using Blip.Entities.Orders;
8namespace Blip.Entities.Customers
10    public class Customer
11    {
12        public Customer()
13        {
14            Orders = new HashSet<Order>();
15        }
17        [Key]
18        [Column(Order = 0)]
19        [DatabaseGenerated(DatabaseGeneratedOption.None)]
20        public Guid CustomerID { get; set; }
22        [Required]
23        [MaxLength(128)]
24        public string CustomerName { get; set; }
26        [Required]
27        [MaxLength(3)]
28        public string CountryIso3 { get; set; }
30        [MaxLength(3)]
31        public string RegionCode { get; set; }
33        public virtual Country Country { get; set; }
35        public virtual Region Region { get; set; }
37        public virtual ICollection<Order> Orders { get; set; }
38    }

Note the following characteristics:

  • ComponentModel DataAnnotations are used to identify the key field for the database and to specify the size and other options.

  • The one-to-many relationship between Customers and Orders is created by the virtual member field comprised of a collection of Order entities.

  • The required relationship between Customers and Countries in the database is reflected in the field to hold the value CountryIso3 and the field to identify the relationship, Country, which is of type Country.


The Order entity is defined in a similar way:

1using System;
2using System.Collections.Generic;
3using System.ComponentModel.DataAnnotations;
4using System.ComponentModel.DataAnnotations.Schema;
5using Blip.Entities.Customers;
6using Blip.Entities.Items;
8namespace Blip.Entities.Orders
10    public class Order
11    {
12        public Order()
13        {
14            Items = new HashSet<Item>();
15        }
17        [Key]
18        [DatabaseGenerated(DatabaseGeneratedOption.None)]
19        public Guid OrderID { get; set; }
21        [Required]
22        public Guid CustomerID { get; set; }
24        [Required]
25        public DateTime OrderDate { get; set; }
27        [Required]
28        [MaxLength(128)]
29        public string Description { get; set; }
31        public virtual ICollection<Item> Items { get; set; }
33        public virtual Customer Customer { get; set; }
34    }

Note that:

  • The Order can only belong to one Customer, as reflected in the field for a single CustomerID and the navigation property Customer.

  • The one-to-many relationship between Orders and Items is implemented through the virtual property for the collection of Item entities.

The data context and Entity Framework code-first migrations for the database are located in the Blip.Data project, along with the repository methods.

Presenting Data

The Blip.Web project in the BlipBinding solution is based on the standard .NET Framework MVC template, so the layout of the views is based on the default Bootstrap CSS styling and the _layout.cshtml included with the template.

For the purposes of this guide, there are two notable views in the case study, one to display a list of customers and another to display a list of orders for each customer.

Customer/Index View

The view for the list of customers is a simple table displaying some basic information about the customer and an Html.ActionLink helper method to navigate to the list of orders for the customer:

BlipBinding Customer/Index view

BlipBinding solution, Blip.Web project, Customer/Index view

Using the Seed method of Entity Framework code-first migrations, we have populated the database with a few customers, orders, and items. If you run the example solution the application will create the database and add the same records (the GUID's created by your computer for the key fields will be different than those shown).


In the code for the view above, note that the list of customers is created with a foreach loop that iterates through the collection of CustomerDisplayViewModel entities. This is a standard way of presenting a list with a variable number of records:

1@foreach(var item in Model)
3    <tr>
4        <td>
5            @Html.DisplayFor(modelItem => item.CustomerID)
6        </td>
7        <td>
8            @Html.DisplayFor(modelItem => item.CustomerName)
9        </td>
10        <td>
11            @Html.DisplayFor(modelItem => item.CountryName)
12        </td>
13        <td>
14            @Html.DisplayFor(modelItem => item.RegionName)
15        </td>
16        <td>
17            @Html.ActionLink("Orders", "Index", "Order",  new { customerid = item.CustomerID }, null)
18        </td>
19    </tr>

In the list of customers, the view model has a flat structure, it's just an enumerable list of objects that contain the customer information. Here's the @model directive from the beginning of Index.cshtml:

1@model IEnumerable<Blip.Entities.Customers.ViewModels.CustomerDisplayViewModel>

Now let's take a closer look at that view model.


1using System;
2using System.ComponentModel.DataAnnotations;
4namespace Blip.Entities.Customers.ViewModels
6    public class CustomerDisplayViewModel
7    {
8        [Display(Name = "Customer Number")]
9        public Guid CustomerID { get; set; }
11        [Display(Name = "Customer Name")]
12        public string CustomerName { get; set; }
14        [Display(Name = "Country")]
15        public string CountryName { get; set; }
17        [Display(Name = "State / Province / Region")]
18        public string RegionName { get; set; }
19    }

Note that we're using data annotations in the view model to provide the field labels to display on the view.

When the repository method populates this model it combines data from the Customers table with CountryNameEnglish from the Country table and RegionName from the Region table. In this way, the view model can present information that is more helpful to the user than the index values for country and region from the Customers table.

Order/Index View

The simple list of orders for a customer shows the customer information and the order number and date as read-only fields, and the purchase order/description as an editable field. By changing values in the editable field and saving we can see how model binding works when doing HttpPost actions.

BlipBinding Order/Index view

BlipBinding solution, Blip.Web project, Order/Index view

In this view, we're presenting information in a hierarchical structure. At the top level is the customer information. Underneath that is the list of orders for the customer.

Let's see how the data is presented in code.


For the top-tier data, pertaining to the customer, the fields are composed in a very standard way:

1        <div class="form-group">
2            @Html.LabelFor(model => model.CustomerName, new { @class = "control-label col-md-2" })
3            <div class="col-md-10">
4                @Html.EditorFor(model => model.CustomerName, new { htmlAttributes = new { @class = "form-control", @readonly = "readonly" } })
5            </div>
6        </div>

Note that we're using the EditorFor HtmlHelper to let the Razor engine determine the correct type of HTML element for the data type. We're also applying the form-control CSS class to be sure the control picks up the appropriate styling. The field is changed from an editable textbox to a display-only field with the application of the @readonly HTML attribute.

For the second tier data, the list of orders, we're looping through the records in the view model. But in this case we're not using a foreach loop and we're not using the EditorFor HtmlHelper. We'll look at the reasons for these choices in more detail in the section on saving data.

1@if (Model.Orders != null)
3    for (var i = 0; i < Model.Orders.Count(); i++)
4    {
5        <tr>
6            @Html.HiddenFor(x => Model.Orders[i].CustomerID)
7            <td>
8                @Html.TextBoxFor(x => Model.Orders[i].OrderID, new { @class = "form-control", @readonly = "readonly" })
9            </td>
10            <td>
11                @Html.TextBoxFor(x => Model.Orders[i].OrderDate, new { @class = "form-control", @readonly = "readonly" })
12            </td>
13            <td>
14                @Html.TextBoxFor(x => Model.Orders[i].Description, new { @class = "form-control" })
15            </td>
16        </tr>
17    }

You'll also note that we're using a for loop with a counting variable rather than a foreach loop. This is crucial to getting binding to work for the 'HttpPost' action, as we'll see soon.


The view model for customer orders reflects the hierarchical structure of the view shown above. It assembles the display information about the customer from the Customers, Countries, and Regions tables and includes a property that is a collection of OrderDisplayViewModel entities.

1using System;
2using System.Collections.Generic;
3using System.ComponentModel.DataAnnotations;
5namespace Blip.Entities.Orders.ViewModels
7    public class CustomerOrdersListViewModel
8    {
9        [Display(Name = "Customer Number")]
10        public Guid CustomerID { get; set; }
12        [Display(Name = "Customer Name")]
13        public string CustomerName { get; set; }
15        [Display(Name = "Country")]
16        public string CountryNameEnglish { get; set; }
18        [Display(Name = "Region")]
19        public string RegionNameEnglish { get; set; }
21        public List<OrderDisplayViewModel> Orders { get; set; }
22    }

Let's take a look at the class that composes the Orders collection.


Note that each entity in OrderDisplayViewModel is linked to the associated customer in CustomerOrdersListViewModel. When we transpose the entities into the view model structure we need to preserve the relationship between the entities (and the tables in the database).

1using System;
2using System.ComponentModel.DataAnnotations;
4namespace Blip.Entities.Orders.ViewModels
6    public class OrderDisplayViewModel
7    {
8        public Guid CustomerID { get; set; }
10        [Display(Name = "Order Number")]
11        public Guid OrderID { get; set; }
13        [Display(Name = "Order Date")]
14        public DateTime OrderDate { get; set; }
16        [Display(Name = "PO / Description")]
17        public string Description { get; set; }
18    }

Note also that there are no virtual properties in either of these classes to provide navigation between entities. The view models serve the functional purpose of the view and are uncoupled from the entity relationships of the classes and the database tables. Accordingly, when two view models are used together they reflect the relationship(s) between the view models, rather than the entities from which their data is drawn.

The repository methods take care of transposing the data from the structure of the entities to the structure of the view models and back again.

OrdersController Action for Index HttpGet

By using MVVM and the repository design pattern, we can make our controller actions succinct and provide separation of concerns between the presentation layer, business logic, and data. We can see that in action in the controller action that populates the Order/Index view.


1using System;
2using System.Net;
3using System.Web.Mvc;
4using Blip.Data.Orders;
5using Blip.Entities.Orders.ViewModels;
7namespace BlipProjects.Controllers
9    public class OrderController : Controller
10    {
11        // GET: Order
12        public ActionResult Index(string customerid)
13        {
14            if(!String.IsNullOrWhiteSpace(customerid))
15            {
16                if (Guid.TryParse(customerid, out Guid customerId))
17                {
18                    var repo = new OrdersRepository();
19                    var model = repo.GetCustomerOrdersDisplay(customerId);
20                    return View(model);
21                }
22            }
23            return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
24        }

All that this controller action needs to do when passed a CustomerID from the Customer/Index view is pass that value to the appropriate repository method and take the resultant data model, an instance of the CustomerOrdersListViewModel class, and pass it to the view.

Saving Data

Saving data using default binding can be a tricky process -- the correct approach is not well-documented. This is also a situation where the code fails silently. Developers will see their web pages being populated with data correctly, but the values won't show up in the model when it arrives at the controller action for HttpPost.

To better understand this, we're first going to take a look at the problem, then show how to code the functionality correctly.

What Not to Do

In the Razor code for the list of customers above, we saw that we could populate the list using a foreach loop and the DisplayFor HtmlHelper method. If we used a foreach loop for the list of orders, the <table> element would look like this:

1<table class="table">
2    <tr>
3        <th>
4            @Html.DisplayNameFor(model => model.Orders[0].OrderID)
5        </th>
6        <th>
7            @Html.DisplayNameFor(model => model.Orders[0].OrderDate)
8        </th>
9        <th>
10            @Html.DisplayNameFor(model => model.Orders[0].Description)
11        </th>
12    </tr>
13    @if (Model.Orders != null)
14    {
15        foreach (var order in Model.Orders)
16        {
17            <tr>
18                @Html.HiddenFor(x => order.CustomerID)
19                <td>
20                    @Html.DisplayFor(x => order.OrderID)
21                </td>
22                <td>
23                    @Html.DisplayFor(x => order.OrderDate)
24                </td>
25                <td>
26                    @Html.EditorFor(x => order.Description)
27                </td>
28            </tr>
29        }
30    }

That's nice and concise, and seems to leverage the power of Razor HtmlHelper extension methods to "automagically" generate HTML. The problem is; it doesn't work.

The HTML produced by the preceding code would look like this:

Ambiguous element ID's

Form Elements with Ambiguous Element Names and ID's

Look at the areas highlighted in yellow. Each row is a separate textbox on the form shown above for the Order/Index view. Each record has the same value for the name and id elements: order.Description. Without a way to identify the records distinctly, MVC gives up and returns null for the Orders field of the CustomerOrdersListViewModel to which the Order/Index view is bound.

Correctly Binding Collection Data

In the Razor code for the list of orders for a specific customer, we used a for loop with a local variable index value i. The loop looks like this:

1@if (Model.Orders != null)
3    for (var i = 0; i < Model.Orders.Count(); i++)
4    {
5        <tr>
6            @Html.HiddenFor(x => Model.Orders[i].CustomerID)
7            <td>
8                @Html.TextBoxFor(x => Model.Orders[i].OrderID, new { @class = "form-control", @readonly = "readonly" })
9            </td>
10            <td>
11                @Html.TextBoxFor(x => Model.Orders[i].OrderDate, new { @class = "form-control", @readonly = "readonly" })
12            </td>
13            <td>
14                @Html.TextBoxFor(x => Model.Orders[i].Description, new { @class = "form-control" })
15            </td>
16        </tr>
17    }

Note the following particulars:

  1. The index value i appears in the lambda expression for each form element being generated by the loop, for example:

    (x => Model.Orders[i].OrderID)

  2. The TextBoxFor HtmlHelper is used, rather than the more general (and automagic) DisplayFor and EditorFor.

  3. The OrderID and OrderDate fields are set as readonly using the HTML class attribute, rather than using DisplayFor.

  4. Bootstrap textbox styling is applied by adding the @class = "form-control" attribute.

The generated HTML looks like this:


Form Elements with a Distinct Name and ID Attributes

As the areas highlighted in yellow show, each field has a name and id attribute that is distinct for each record. The record index gives MVC something to use to bind the form data to the data model.

By setting a breakpoint in the HttpPost controller action for the Order/Index view we can see that the new data we entered, "expedite" in the Description field of record 0, is being posted back to the server along with the values for the readonly fields.

Visual Studio property inspector

Visual Studio Debugging Showing Property Inspector Values for an OrderDisplayViewModel Entity

Posting Display Data

As we noted above, we're using the TextBoxFor HtmlHelper for read-only fields, rather than the DisplayFor helper. This is so we can return the data to the controller when the HttpPost event fires (when the Save button on the page is pressed).

When MVC generates the HTML for a DisplayFor HTML helper, it renders the element as simple text.

For example, a table cell coded like this:

2    @Html.DisplayFor(modelItem => item.OrderID)

would render like this:

2    490dabe1-1570-473a-8331-5f32333b2635

That's just straight text, so there's no way for MVC to bind it to the model.

But a table cell for a display-only text field coded like this:

2    @Html.TextBoxFor(x => Model.Orders[i].OrderID, new { @class = "form-control", @readonly = "readonly" })

would render like this:

2    <input name="Orders[0].OrderID" class="form-control" id="Orders_0__OrderID" type="text" readonly="readonly" value="490dabe1-1570-473a-8331-5f32333b2635" data-val-required="The Order Number field is required." data-val="true">

As an <input> field, this element will be passed back to the controller during the POST. Because it has a distinct id, the data in this readonly field can be bound to the view model just like data from an editable field. The values for CustomerID and OrderID give us the index values necessary to save the changes to the editable field, Description.

Note, also, that while we're displaying OrderDate as a readonly text field, and thereby returning it during the POST event, we don't have to. Only the index values necessary to save the changed data need to be returned in the POST event.

In our example, the OrderID field is displayed, but the CustomerID field is included in the form using the HiddenFor HtmlHelper. As coded, it looks like this:

1@Html.HiddenFor(x => Model.Orders[i].CustomerID)
4And the HTML rendered by it looks like this:
7<input name="Orders[0].CustomerID" id="Orders_0__CustomerID" type="hidden" value="f8214550-69f6-4089-b58a-2de2d4ab01c8" data-val-required="The CustomerID field is required." data-val="true">

Aside from being hidden on the HTML served to the client, this is a full-featured data element that is bound to the view model received by the HttpPost controller action for the Index view. Using HiddenFor is a convenient way of keeping form layout simple while including all the data necessary for identifying the records to be updated during a POST.

The astute reader may have realized that in our case study the CustomerID field isn't necessary to save changes to an Order object. Because OrderID is a GUID it inherently provides a unique identifier for an individual order.

HttpPost Controller Actions

By using the repository design pattern we can keep our controller actions simple. In the case of the HttpPost action for the Order/Index view, all we need to do is validate CustomerOrdersListViewModel and pass it to the appropriate repository method.


4public ActionResult Index(CustomerOrdersListViewModel model)
6    if (ModelState.IsValid)
7    {
8        if (model.Orders != null)
9        {
10            var repo = new OrdersRepository();
11            repo.SaveOrders(model.Orders);
12        }
13        return View(model);
14    }
15    return new HttpStatusCodeResult(HttpStatusCode.BadRequest);

Save Repository Method

The repository method associated with our order list view can also be simple. All we need to do is find the appropriate records in the Orders table and update them.


2public void SaveOrders(List<OrderDisplayViewModel> orders)
4    if (orders != null)
5    {
6        using (var context = new ApplicationDbContext())
7        {
8            foreach (var order in orders)
9            {
10                var record = context.Orders.Find(order.OrderID);
11                if (record != null)
12                {
13                    record.Description = order.Description;
14                }
15            }
16            context.SaveChanges();
17        }
18    }


If you want to dive deeper into the topics discussed in this guide, the following is a curated list of resources.

Related PluralSight Training Classes

PluralSight offers a number of courses on the topics mentioned in this guide. The following are some suggestions organized by technology:

Case Study Code on GitHub

The complete Visual Studio solutions described in this guide are available on GitHub:

You can fork the projects, run the code, and experiment on your own.

Note that the sample project is not intended to be a real-life case study or production code; it exists to illustrate the topics covered in this guide.

Other Resources

Disclaimer: Pluralsight and the author of this Guide are not responsible for the content, accuracy, or availability of 3rd party resources.

Microsoft Entity Framework Documentation
This page is the jumping off point for a variety of resources on EF.

Microsoft: The Repository Pattern
This page provides a summary of the benefits of the repository pattern.