The ASP.NET Core MVC Pattern in Practice: Controllers, Views, ViewModels and Conventional Routing
In ASP.NET Core MVC a controller derives from Controller, finds its view by name in Views/<Controller> and Shared, and gets page data through a type-safe ViewModel instead of ViewBag. On Bookstore we look at the dotnet new mvc layout, conventional routing, RedirectToAction, an Admin area and a real bug where swapping route order makes links silently point to the wrong address.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402) and Microsoft.EntityFrameworkCore.Sqlite 10.0.14.
Guess First
Bookstore has two BooksControllers: one in the public catalog, the other in the Admin area. Every row in the admin list has this link: <a asp-controller="Books" asp-action="Details" asp-route-id="1">. When Program.cs registers the area route first and the default route second, the link produces /Admin/Books/Details/1. Now you only swap the order of the two MapControllerRoute calls. Does the link stay the same? Does the application fail at startup? Or, worse, does it quietly produce a different address?
What Does dotnet new mvc Set Up?
We covered the theory of the MVC (Model-View-Controller) pattern, that is, how it changed on its way from Smalltalk to the web, in the UI (User Interface) Architecture Patterns series post "The MVC Pattern: Original Smalltalk MVC and Web MVC Are Not the Same". This post is the practical side: in ASP.NET Core MVC, which files, classes and rules do those three roles map to? The files produced by dotnet new mvc -n Bookstore.Web (excluding Bootstrap, jQuery and jQuery Validation under wwwroot/lib):
./Bookstore.Web.csproj
./Controllers/HomeController.cs
./Models/ErrorViewModel.cs
./Program.cs
./Properties/launchSettings.json
./Views/Home/Index.cshtml
./Views/Home/Privacy.cshtml
./Views/Shared/Error.cshtml
./Views/Shared/_Layout.cshtml
./Views/Shared/_Layout.cshtml.css
./Views/Shared/_ValidationScriptsPartial.cshtml
./Views/_ViewImports.cshtml
./Views/_ViewStart.cshtml
./appsettings.Development.json
./appsettings.json
./wwwroot/css/site.css
./wwwroot/favicon.ico
./wwwroot/js/site.js
The folder names are conventions: controllers in Controllers, views under Views/<ControllerName>, layouts and partials shared by every controller in Views/Shared. The .csproj only enables the Microsoft.NET.Sdk.Web SDK (Software Development Kit), net10.0, Nullable and ImplicitUsings; Bootstrap 5.3.3, jQuery 3.7.1, jQuery Validation 1.21.0 and jQuery Validation Unobtrusive 4.0.0 come with the template not as packages but as files copied under wwwroot/lib.
For Bookstore we added SQLite through Entity Framework Core (EF Core), a service and the Admin area route to the template's Program.cs:
using Bookstore.Web.Data;
using Bookstore.Web.Services;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddDbContext<BookstoreDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Bookstore")));
builder.Services.AddScoped<IBookCatalog, BookCatalog>();
var app = builder.Build();
// Demo only: create the database and seed data (use migrations in a real project)
using (IServiceScope scope = app.Services.CreateScope())
{
BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
db.Database.EnsureCreated();
}
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllerRoute(
name: "areas",
pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}")
.WithStaticAssets();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}")
.WithStaticAssets();
app.Run();
AddControllersWithViews() adds view support on top of the AddControllers() you use in Web API (Application Programming Interface) projects: the Razor view engine, tag helpers, TempData and antiforgery. MapStaticAssets() and WithStaticAssets() are the static asset infrastructure introduced in .NET 9; the details are in the 9th post of this series.
Conventional Routing: Where Do the Addresses Come From?
In the Web API series we used attribute routing: every action carried its own route through an attribute such as [HttpGet("{id}")]. What is common in MVC applications is conventional routing: a single template sets the address rule for all controllers. The template {controller=Home}/{action=Index}/{id?} has three segments and each segment produces a route value. =Home and =Index are default values, ? means optional. The real results of requests I sent to the application:
$ for u in / /Books /books/details/2 /Books/Details/6 /Books/Details/abc /Home/Index/7; do
curl -s -o /dev/null -w "%{http_code} %{url_effective}\n" "http://localhost:17711$u"; done
200 http://localhost:17711/
200 http://localhost:17711/Books
200 http://localhost:17711/books/details/2
404 http://localhost:17711/Books/Details/6
404 http://localhost:17711/Books/Details/abc
200 http://localhost:17711/Home/Index/7
Thanks to the defaults, / goes to HomeController.Index and /books/details/2 goes to BooksController.Details(2). /Books/Details/6 is a draft book; the service returns null, hence the 404. Three more details matter. Route matching is case-insensitive. /Books/Details/abc matches the route because the {id?} segment has no type constraint; when model binding cannot convert abc to int, id enters the action with its default value 0 and an error is written to ModelState. We got a 404 because there is no book number 0, but that is a coincidence. Had you written {id:int?}, the request would have been a 404 at the routing stage. The last line shows a property of conventional routing too: since the Index action takes no id, the value 7 is silently ignored.
The Controller Class: Controller or ControllerBase?
The catalog controller:
using Bookstore.Web.Services;
using Bookstore.Web.ViewModels;
using Microsoft.AspNetCore.Mvc;
namespace Bookstore.Web.Controllers;
public class BooksController(IBookCatalog catalog) : Controller
{
public async Task<IActionResult> Index(string? search, int page = 1, CancellationToken cancellationToken = default)
{
BookCatalogViewModel model = await catalog.ListAsync(search, page, cancellationToken);
return View(model);
}
public async Task<IActionResult> Details(int id, CancellationToken cancellationToken)
{
BookDetailsViewModel? book = await catalog.GetDetailsAsync(id, cancellationToken);
if (book is null)
{
return NotFound();
}
return View(book);
}
// Old search address: /Books/Search?q=Ink
public IActionResult Search(string? q) => RedirectToAction(nameof(Index), new { search = q });
}
In Web API, controllers derived from ControllerBase. In MVC we derive from Controller. Controller is a subclass that adds view-specific members on top of ControllerBase: View(), PartialView(), ViewComponent(), ViewData, ViewBag, TempData and Json(). When you write the same action in a class deriving from ControllerBase, the compiler says:
Controllers/ApiProbeController.cs(7,37): error CS0103: The name 'View' does not exist in the current context
Of the results an action can return, the ones you use most on the view side are View() (a full page with the layout), PartialView() (a fragment without the layout, in posts 4 and 11), RedirectToAction() (sending to another action with a 302) and NotFound(). The Search action uses RedirectToAction to move an old address to the new one. The real response:
HTTP/1.1 302 Found
Content-Length: 0
Date: Sun, 08 Nov 2026 02:03:24 GMT
Server: Kestrel
Location: /Books?search=Ink
We did not write the Location header. RedirectToAction generated the URL (Uniform Resource Locator) from the route table; since search is not a segment in the template, it went into the query string. Letting routing generate the address instead of concatenating it by hand means every link fixes itself when the route template changes later. It is also the cornerstone of Post-Redirect-Get in the 6th post.
How Is a View Found?
The View(model) call gives no file name. MVC takes the view name from the action name and looks first in the controller's folder, then in Shared. The clearest illustration is the error you get when you ask for a view that does not exist. The real exception logged when calling a Featured action that has no view file:
System.InvalidOperationException: The view 'Featured' was not found. The following locations were searched:
/Views/Books/Featured.cshtml
/Views/Shared/Featured.cshtml
For an action in the Admin area the same lookup checks three places; after the area's own folders it falls back to the application's common Shared folder:
System.InvalidOperationException: The view 'Report' was not found. The following locations were searched:
/Areas/Admin/Views/Books/Report.cshtml
/Areas/Admin/Views/Shared/Report.cshtml
/Views/Shared/Report.cshtml
The practical consequence of this order: Views/Shared/_Layout.cshtml is visible from areas too, but if you create a file with the same name inside the area, that one wins. Passing a name, as in View("Edit", model), triggers the same search; passing a full path such as View("~/Views/Books/Edit.cshtml", model) skips it.
ViewModel: Exactly the Data the View Needs
What do we send to the view? The shortcut is sending the EF Core entity as is. The Book entity has Stock, IsFeatured, Status, AuthorId and the Author navigation property. A catalog row needs four fields out of these. So we define immutable records specific to the view:
using Bookstore.Web.Models;
namespace Bookstore.Web.ViewModels;
public record BookListItemViewModel(int Id, string Title, string AuthorName, decimal Price, BookStatus Status);
public record BookCatalogViewModel(
IReadOnlyList<BookListItemViewModel> Books, string? Search, int Page, int TotalPages);
public record BookDetailsViewModel(
int Id, string Title, string AuthorName, decimal Price, int Stock, DateOnly PublishedOn, Genre Genre, BookStatus Status)
{
public bool InStock => Stock > 0;
}
A ViewModel does three jobs. It documents, as a type, what the view needs. It keeps the entity from leaking into the view, and with it surprise lazy loading queries while the view renders. And it gives small presentation logic such as InStock a testable home instead of the view. The @model directive on the first line of the view binds this type and makes the Model property type safe:
@model BookCatalogViewModel
@{
ViewData["Title"] = "Books";
}
<h1>Books</h1>
<table class="table">
<thead>
<tr><th>Title</th><th>Author</th><th>Price</th></tr>
</thead>
<tbody>
@foreach (BookListItemViewModel book in Model.Books)
{
<tr>
<td><a asp-action="Details" asp-route-id="@book.Id">@book.Title</a></td>
<td>@book.AuthorName</td>
<td>@book.Price.ToString("0.00")</td>
</tr>
}
</tbody>
</table>
<p>Page @Model.Page of @Model.TotalPages</p>
The table body in the real output of GET /Books?search=Ink:
<tbody>
<tr>
<td><a href="/Books/Details/2">Rivers of Ink</a></td>
<td>Alan Brooks</td>
<td>265.50</td>
</tr>
</tbody>
</table>
<p>Page 1 of 1</p>
What about ViewData and ViewBag? Both reach the same dictionary in two ways: ViewData["Heading"] is a string-keyed dictionary, and ViewBag.Heading is its dynamic wrapper. The problem is that the compiler can check neither. I wrote ViewBag.Heading = "Staff picks"; in the controller and, by mistake, <h1>@ViewBag.Headline</h1> in the view. The application compiled, the page returned 200, and the real output was:
<h1></h1>
<p>Coming soon.</p>
No compile error, no exception, no log. The heading silently disappeared. Had you made the same mistake with a ViewModel, the Model.Headline line would not have compiled. So my rule is: ViewData only for small things the layout expects from the page (such as ViewData["Title"]); the page's real data always goes in a ViewModel.
A Service Instead of a Fat Controller
Our controller has not a single LINQ (Language Integrated Query) line; IBookCatalog does the work:
public class BookCatalog(BookstoreDbContext db) : IBookCatalog
{
private const int PageSize = 4;
public async Task<BookCatalogViewModel> ListAsync(string? search, int page, CancellationToken cancellationToken = default)
{
IQueryable<Book> query = db.Books.AsNoTracking().Where(b => b.Status != BookStatus.Draft);
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(b => b.Title.Contains(search));
}
int total = await query.CountAsync(cancellationToken);
int totalPages = Math.Max(1, (int)Math.Ceiling(total / (double)PageSize));
page = Math.Clamp(page, 1, totalPages);
List<BookListItemViewModel> books = await query
.OrderBy(b => b.Title)
.Skip((page - 1) * PageSize)
.Take(PageSize)
.Select(b => new BookListItemViewModel(b.Id, b.Title, b.Author.Name, b.Price, b.Status))
.ToListAsync(cancellationToken);
return new BookCatalogViewModel(books, search, page, totalPages);
}
public Task<BookDetailsViewModel?> GetDetailsAsync(int id, CancellationToken cancellationToken = default) =>
db.Books.AsNoTracking()
.Where(b => b.Id == id && b.Status != BookStatus.Draft)
.Select(b => new BookDetailsViewModel(
b.Id, b.Title, b.Author.Name, b.Price, b.Stock, b.PublishedOn, b.Genre, b.Status))
.FirstOrDefaultAsync(cancellationToken);
}
The rule "a draft book does not appear in the catalog" lives in the service, not the controller; the same rule will later be used by a Razor Page or an API endpoint as well. Because the projection (Select) goes straight into the ViewModel, only the needed columns come from the database and the entity is never tracked. We discussed the service layer and the "do we need a repository" debate in the ASP.NET Core Web API series post "Layered Structure in ASP.NET Core Web API: The Service Layer and the Repository Debate"; the same reasoning applies here. The only difference is that this service returns ViewModels directly. That is a deliberate choice: BookCatalog is a query service that lives inside the Web project and serves the screen. If the business rules lived in a separate domain layer, that layer would not know about ViewModels.
Areas: Separating the Admin Panel
The admin panel works with the same entities as the catalog but serves a different user, with different permissions and a different layout. An area opens a subsection of the application with its own Controllers, Views and route:
using Bookstore.Web.Data;
using Bookstore.Web.ViewModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Web.Areas.Admin.Controllers;
[Area("Admin")]
public class BooksController(BookstoreDbContext db) : Controller
{
public async Task<IActionResult> Index(CancellationToken cancellationToken)
{
List<BookListItemViewModel> books = await db.Books.AsNoTracking()
.OrderBy(b => b.Id)
.Select(b => new BookListItemViewModel(b.Id, b.Title, b.Author.Name, b.Price, b.Status))
.ToListAsync(cancellationToken);
return View(books);
}
}
Both BooksControllers have the same name but different namespaces, and the [Area("Admin")] attribute gives the second one the route value area = "Admin". The file layout: Areas/Admin/Controllers/BooksController.cs and Areas/Admin/Views/Books/Index.cshtml. The area's views do not see the application's Views/_ViewImports.cshtml; that is why we also added Areas/Admin/Views/_ViewImports.cshtml and _ViewStart.cshtml. The {area:exists} constraint only matches an area name that really exists.
There is a subtlety when generating links inside an area view. I wrote two links in the admin list:
<td><a asp-controller="Books" asp-action="Details" asp-route-id="@book.Id">Admin view</a></td>
<td><a asp-area="" asp-controller="Books" asp-action="Details" asp-route-id="@book.Id">Store page</a></td>
The real output:
<td><a href="/Admin/Books/Details/1">Admin view</a></td>
<td><a href="/Books/Details/1">Store page</a></td>
We specified no area in the first link, but the current request's area = "Admin" value (an ambient value) carried over into link generation. In the second we deliberately cleared it with asp-area="". That is why the menu links in the template's _Layout.cshtml say asp-area="": the layout is used inside areas as well, and the "Home" link printed from the admin panel must not go to /Admin/Home.
In Production
The answer to the opening question. I swapped the route order and registered the default route first. The application started fine, /Admin/Books still returned 200, but the HTML (HyperText Markup Language) of the same page turned into this:
<td><a href="/Books/Details/1">Admin view</a></td>
<td><a href="/Books/Details/1">Store page</a></td>
The "Admin view" link now points to the public catalog page. No error, no warning. Conventional routes depend on order and link generation uses that order too; when the more general default route came first, it was able to produce a URL that does not carry the area value. The rule in Microsoft's routing documentation is short: register area routes first because they are more specific. Only an integration test that checks links, or a careful eye, catches this kind of bug.
Second, 404s. /Books/Details/6 returned a 404 with an empty body because we configured no status code page. A user-facing application needs a middleware such as app.UseStatusCodePagesWithReExecute("/Home/StatusCode/{0}") to show a "not found" page with the layout. Third, CancellationToken: if the user closes the page while it loads or clicks another link, the token passed to the action is cancelled and the EF Core query is cut short too. The details are in the Web API series post on Async/Await and CancellationToken.
Common Pitfalls
Sending the entity to the view. When the view touches Model.Author.Name, you get a separate query per row if lazy loading is on, and a null reference if it is off. And a sensitive property added to the entity one day travels all the way to the view without anyone noticing.
Carrying page data in ViewBag. The empty <h1> example above is its most innocent form. When I made the same typo in a loop such as @foreach (string tag in ViewBag.Tagz), the page returned 500 with a System.NullReferenceException in the log; the bug surfaced not at compile time but on the user's screen.
The fat controller. A 20-line LINQ query, business rules and mapping in every action. When the same rule is needed on a second screen it gets copied, and the two copies drift apart over time.
Not putting a type constraint on route values. {id?} accepts anything; abc turns into 0 and you see a 404 that behaves correctly by accident. {id:int?} or a ModelState.IsValid check in the action makes the intent explicit.
Forgetting _ViewImports.cshtml in the area. The @model List<BookListItemViewModel> line fails to compile with error CS0246: The type or namespace name 'BookListItemViewModel' could not be found. When I got past that error by writing the type's full name, the sneakier problem showed up: tag helpers did not run and <a asp-controller="Books" asp-action="Details" asp-route-id="1"> was printed into the HTML as is; the link never got an href.
A Small Experiment
★☆☆ Change the default route template to {controller=Home}/{action=Index}/{id:int?}. Does the result of /Books/Details/abc change? Does the action run according to the log?
★★☆ Add a Details action and view to the Admin area. Then deliberately reverse the route order and click the "Admin view" link. Which controller's action runs?
★★★ Rewrite the area route with app.MapAreaControllerRoute(name: "admin", areaName: "Admin", pattern: "admin/{controller=Books}/{action=Index}/{id?}") and remove the route with {area:exists}. Where does /Admin go now? Is link generation still affected by route order?
Controllers, ViewModels and the area are ready. But our views are still just a few @ expressions sprinkled over bare HTML. In the next post we go down to Razor syntax itself: implicit and explicit expressions, how default HTML encoding protects us from XSS (Cross-Site Scripting), the danger of @Html.Raw, layouts, sections, and the _ViewStart and _ViewImports hierarchy.