| Lesson 5 | Displaying data on the Web |
| Objective | Display data using bound controls. |
Displaying Data Using Bound Controls: Binding Markup to the Model
Lesson 3 built a reusable query against VirtualBookShelf's Inventory table, SELECT BookID, Title, Author, Price FROM Inventory, and Lesson 2 showed how to read each row from a SqlDataReader into a Book object and print it into an HTML table by hand, one hardcoded <th> and one hardcoded @book.Title at a time. That hand-written approach works, but it means the table's structure lives in two places at once, the model class that defines what a book has, and the markup that spells out, separately, what each column should be labeled. This lesson closes that gap: rather than typing "Title" as a column header and typing @book.Title as the value, the same four fields this course has used since Lesson 3 get displayed through markup that reads its structure directly from the model, so the table's labels and values stay tied to the one place that actually defines them.
What "Bound Controls" Actually Meant Here
"Bound controls" is this course's own term for connecting a Visual InterDev page element directly to a data source at design time, covered already in Lesson 2's Design Time Controls and reused again in this lesson's legacy title. It's worth being precise about what it referred to, since the same phrase, "bound controls," also names a real but unrelated piece of Microsoft history: ASP.NET Web Forms, a later, fully compiled .NET technology built around controls like <asp:GridView> and a DataBind() method call. This course has never used Web Forms and isn't modernizing it; VirtualBookShelf's legacy stack has always been Classic ASP, VBScript, Visual InterDev, and classic ADO, all of it running before .NET existed at all. Where this lesson says "bound controls," it means the Visual InterDev sense already established in Lesson 2 and Lesson 3, not the Web Forms sense, the two technologies share a name for a similar idea but come from different eras of Microsoft's own history.
Adding Display Metadata to the Book Model
Lesson 2's
Book class holds four properties,
BookID,
Title,
Author, and
Price, and nothing about how each one should be labeled when it's shown on a page. A
Display attribute from
System.ComponentModel.DataAnnotations adds exactly that, a human-readable name attached directly to the property it describes, rather than left to be retyped separately wherever the property gets rendered:
using System.ComponentModel.DataAnnotations;
public class Book
{
[Display(Name = "Book ID")]
public int BookID { get; set; }
[Display(Name = "Title")]
public string Title { get; set; } = string.Empty;
[Display(Name = "Author")]
public string Author { get; set; } = string.Empty;
[Display(Name = "Price")]
[DataType(DataType.Currency)]
public decimal Price { get; set; }
}
Nothing about
Book's actual data changed, retrieving it with the
SqlConnection,
SqlCommand, and
SqlDataReader pattern from Lessons 1 through 3 still works exactly the same way. What changed is that the class itself now carries information about how it should be presented, a label for each field, and, for
Price, a
DataType hint saying this number represents currency. That second attribute matters more than it looks: it's what lets the rendering code below format
Price correctly without a single line of formatting code being written by hand.
Rendering the Table From the Model's Own Metadata
Lesson 2 rendered VirtualBookShelf's catalog like this, every header and every value typed out directly in the markup:
<table>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Price</th>
</tr>
</thead>
<tbody>
@foreach (var book in Model.Inventory)
{
<tr>
<td>@book.Title</td>
<td>@book.Author</td>
<td>@book.Price.ToString("C")</td>
</tr>
}
</tbody>
</table>
This works, and nothing about it is wrong, but every header text and every formatting call (
.ToString("C") for currency) had to be typed by hand, separately from the
Book class that already knows what these fields are.
@Html.DisplayNameFor and
@Html.DisplayFor read the
Display and
DataType attributes added above and generate the same output from the model itself:
<table>
<thead>
<tr>
<th>@Html.DisplayNameFor(m => m.Inventory[0].Title)</th>
<th>@Html.DisplayNameFor(m => m.Inventory[0].Author)</th>
<th>@Html.DisplayNameFor(m => m.Inventory[0].Price)</th>
</tr>
</thead>
<tbody>
@foreach (var book in Model.Inventory)
{
<tr>
<td>@Html.DisplayFor(_ => book.Title)</td>
<td>@Html.DisplayFor(_ => book.Author)</td>
<td>@Html.DisplayFor(_ => book.Price)</td>
</tr>
}
</tbody>
</table>
DisplayNameFor takes a lambda pointing at a property,
m.Inventory[0].Title, and reads that property's
Display attribute to produce the header text, "Title" in this case, without that text being typed anywhere in the markup itself.
DisplayFor does the equivalent for a value: it reads
book.Price, sees the
DataType.Currency attribute on the
Price property, and formats it as currency automatically, the same result
.ToString("C") produced above, generated from the model's own metadata instead of a formatting call repeated at every place
Price gets displayed. If
Price's formatting rule ever needs to change, site-wide, it changes once, on the
Book class, rather than everywhere
Price happens to be rendered.
This is the real modern parallel to what the legacy Visual InterDev workflow was actually praising when it described dragging fields onto a page: the table's structure, which columns exist and what they're called, comes from the data's own shape rather than being retyped by hand for every field. Dragging a field in a design-time tool and reading a
Display attribute from a model class are different mechanisms from very different eras, but they solve the same underlying problem, keeping a table's markup from drifting out of sync with what the data actually is.
Applying the Same Pattern to Customer
Book isn't the only class this technique applies to. VirtualBookShelf's
Customer table, established back in Module 2 and queried directly since Module 3, has its own set of properties worth the same treatment once a page exists to display them,
CustomerID,
FirstName,
LastName,
Email,
Phone, and
DateJoined. The same two attributes used on
Book apply here too, along with a couple of
DataType values that weren't relevant to anything
Book holds:
using System.ComponentModel.DataAnnotations;
public class Customer
{
[Display(Name = "Customer ID")]
public int CustomerID { get; set; }
[Display(Name = "First Name")]
public string FirstName { get; set; } = string.Empty;
[Display(Name = "Last Name")]
public string LastName { get; set; } = string.Empty;
[Display(Name = "Email")]
[DataType(DataType.EmailAddress)]
public string Email { get; set; } = string.Empty;
[Display(Name = "Phone")]
[DataType(DataType.PhoneNumber)]
public string? Phone { get; set; }
[Display(Name = "Date Joined")]
[DataType(DataType.Date)]
public DateTime DateJoined { get; set; }
}
DataType.EmailAddress and
DataType.PhoneNumber don't change how these values are stored or retrieved,
Email is still a plain
nvarchar(255) column in SQL Server, they change how
DisplayFor renders the value and, later in this course, how a Tag Helper-driven form built against this same class renders its input element.
DataType.Date on
DateJoined tells
DisplayFor to render just the date portion of a .NET
DateTime, without a time component that
Customer.DateJoined's underlying SQL Server
date column never actually had in the first place. A page displaying VirtualBookShelf's customers, whenever this course builds one, gets the same benefit this lesson's catalog page just did: labels and formatting that come from the model once, rather than being retyped at every page that happens to show a customer's details.
Selecting Only the Columns You Need
This lesson's legacy content made a point of selecting exactly four fields, ItemNo, Title, Author, and Retail in its own terms, rather than every column a table happens to have. That's the same habit Module 3 already established in its own lesson contrasting SELECT * against naming columns explicitly: Lesson 3's query, SELECT BookID, Title, Author, Price FROM Inventory, names exactly the four columns this page displays, nothing more. The Book class mirrors that same restraint, it has four properties, matching the four columns the query actually retrieves, not a stand-in for every column Inventory might ever contain. A page's model class and the query feeding it are worth keeping in that same kind of lockstep deliberately: a property this page's markup never displays is a property worth asking whether the query should be retrieving at all.
Running the Query Behind This Page
Nothing about retrieving VirtualBookShelf's catalog changes in this lesson, the same connection, command, and reader pattern from Lessons 1 through 3 still does the work. The page model's
OnGet handler looks the same as it has throughout this module:
public async Task OnGetAsync()
{
using SqlConnection connection = new(connectionString);
await connection.OpenAsync();
SqlCommand command = new("SELECT BookID, Title, Author, Price FROM Inventory", connection);
using SqlDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
Inventory.Add(new Book
{
BookID = reader.GetInt32(reader.GetOrdinal("BookID")),
Title = reader.GetString(reader.GetOrdinal("Title")),
Author = reader.GetString(reader.GetOrdinal("Author")),
Price = reader.GetDecimal(reader.GetOrdinal("Price"))
});
}
}
What changed in this lesson lives entirely in the
Book class and the Razor markup rendering it, not in how the data gets retrieved. That separation is worth noticing on its own: the
Display and
DataType attributes added above only affect presentation, they have no bearing on the SQL that runs or the values a
SqlDataReader hands back. A page's data-access code and its presentation metadata can change independently of each other, exactly the kind of separation this course has been building toward since Lesson 1 distinguished a connection string from the markup that eventually displays what it retrieves.
Frequently Asked Questions
Does DisplayFor replace the @foreach loop this module has used since Lesson 1? No. The loop still runs exactly the same way, once per row in Model.Inventory. DisplayFor only changes what happens inside each iteration, how a single property gets rendered, not how many times the markup repeats.
Why use @Html.DisplayNameFor instead of a Tag Helper, when Lesson 4 just introduced Tag Helpers as this course's preferred mechanism? Tag Helpers and HTML Helpers like DisplayNameFor solve overlapping but different problems. Tag Helpers, asp-for above all, are built around a single bound element, a form input or a label tied to one property. Generating a table header's text from a property's metadata, with no actual form element involved, is the kind of job DisplayNameFor still handles directly, and it's exactly the pattern Visual Studio's own scaffolding tools generate for a data table today. Both mechanisms read the same underlying Display attribute; which one applies depends on whether there's a bound form element in play or just a label to generate.
Does asp-for from Lesson 4 use these same Display attributes? Yes. A <label asp-for="NewProduct.Name">, the pattern Lesson 4 introduced for forms, reads that same property's Display attribute to generate its label text, the identical metadata this lesson's table headers read. Adding a Display attribute to a property benefits both a form built with Tag Helpers and a table built with DisplayNameFor, from the same one place.
Does any of this require Entity Framework Core? No. Display and DataType are plain C# attributes from System.ComponentModel.DataAnnotations, they work on any class, bound to data through ADO.NET or otherwise. Nothing in this lesson depends on EF Core, which this course still hasn't introduced.
What happens if a property has no Display attribute at all? DisplayNameFor falls back to the property's own name, BookID would render as the literal text "BookID" rather than a friendlier label. The attribute is optional, it only changes the fallback behavior, it doesn't change whether the property can be displayed at all.
Does SQL Server 2025 change anything about how this page connects or queries? Not for anything this lesson covers. SQL Server 2025 does add real new capabilities worth knowing about, native support for working with JSON documents directly, built-in vector search for similarity queries, and Optimized Locking, a storage-engine change that reduces lock memory and blocking under concurrent UPDATE and INSERT activity by tracking transactions individually rather than by row. None of VirtualBookShelf's tables use JSON or vector columns, and nothing in this lesson's single-table SELECT needs a WITH clause or a VECTOR_DISTANCE calculation, so these remain names worth recognizing rather than tools this lesson had a reason to use.
Could the four fields this lesson displays ever come from more than one table? Yes, eventually, a book's data and, say, a publisher's name living in a separate table would need a JOIN, already acknowledged as a forward-looking topic in Lesson 2's own FAQ. Nothing about Display attributes or DisplayFor changes once a query starts pulling from more than one table; the rendering side of this lesson stays the same regardless of how many tables the underlying query touches.
Is it worth adding a Display attribute to every property on every model in this project, even ones that aren't rendered through DisplayFor yet? It's reasonable to do as each property's intended label becomes clear, since the attribute itself has no runtime cost if nothing happens to read it yet. It's just as reasonable to add it only once a specific page actually needs it, the way this lesson added it to Book only once a page existed that benefited from reading it.
What if two different pages need two different labels for the same property? A Display attribute on the class itself applies everywhere that class is used, so two pages sharing one Book class get the same label from it by default. A page genuinely needing a different label for the same underlying data is better served by its own small view-specific class, with its own Display attributes, than by changing what Book itself says; nothing in this lesson's examples needed that, since one Book class serves one catalog page so far.
Does adding a DataType attribute validate the data, rejecting a malformed email address or phone number? Not on its own, for retrieval and display. DataType mainly affects formatting and, later in this course, which kind of HTML input element a Tag Helper generates for a bound form field. Actual validation, rejecting an invalid Email value before it reaches the database, uses separate attributes, [EmailAddress] or [Phone], which a page's OnPostAsync handler checks through ModelState.IsValid, the same check Lesson 3's command-object examples already relied on for a successful INSERT.
What's Ahead
Every example since Lesson 2 has displayed VirtualBookShelf's entire Inventory table at once, a reasonable approach while the catalog stays small, but not one that scales to a catalog with thousands of books. The next lesson adds paging to this same page, breaking that full result set into smaller pages a visitor can move through, the modern equivalent of the RecordsetNavBar control this course's legacy material once added to a page for exactly the same reason. The Book class, its Display attributes, and the DisplayFor-driven table built in this lesson carry forward unchanged; what's added is a way to request one page of results at a time instead of every row Inventory contains.
