Reading about MVC is not the same as being able to build with it. This tutorial closes that gap by walking one feature — a Todo manager — from URL to database and back. The pieces are deliberately small so you can reproduce them in a fresh ASP.NET Core project on .NET 10 and see each boundary with your own eyes.
If the pattern itself is still fuzzy, read Part 5 first: What Is MVC? Model-View-Controller Explained. Otherwise, continue.
Step 1 — The controller and its actions
Everything starts with a controller class. In this feature it lives under Areas/Main/Todo/Controllers and is decorated with the area name and an authorization rule. Each action returns a view by an explicit path:
[Area("Main")]
[Authorize(Roles = $"{ApplicationRoles.AdminConst},{ApplicationRoles.MemberConst}")]
public class TodoController : Controller
{
public IActionResult Index()
=> View("~/Areas/Main/Todo/Views/Index.cshtml");
public IActionResult Create()
=> View("~/Areas/Main/Todo/Views/Create.cshtml");
public IActionResult Edit(string id)
{
ViewBag.TodoId = id;
return View("~/Areas/Main/Todo/Views/Edit.cshtml");
}
public IActionResult Detail(string id)
{
ViewBag.TodoId = id;
return View("~/Areas/Main/Todo/Views/Detail.cshtml");
}
}
Notice what these actions do not do: no database access, no validation, no business rules. Each one answers a single question — which screen should this URL render — and passes an identifier through ViewBag when the screen needs one. That discipline is what keeps the controller readable as the feature grows.
Step 2 — Routing: how a URL becomes an action
ASP.NET Core matches incoming URLs against route patterns. For areas, the conventional template is {area:exists}/{controller=Home}/{action=Index}/{id?}, so a request to /Main/Todo/Edit/todo-42 resolves to the Edit action of TodoController in the Main area, with id bound to todo-42. The action parameter name matches the route token, which is why it is declared as Edit(string id).
Two conventions keep routing predictable in business applications:
- Pages use MVC routes that mirror the navigation structure people see in the browser.
- Data operations use explicit API routes such as
/api/todo, mapped separately from the page routes.
That split is the architectural backbone of the rest of this tutorial, and Part 9 explains how to keep both surfaces organized per feature.
Step 3 — The Razor view
A view is a .cshtml file that mixes HTML with server-side Razor expressions. The list screen starts with a title and layout assignment, then renders a table shell that its JavaScript will populate:
@{
ViewData["Title"] = "Todo Management";
Layout = "/Areas/_LayoutArea.cshtml";
}
<table id="todoTable" class="table align-middle mb-0" style="width:100%;">
<thead>
<tr>
<th>Auto Number</th>
<th>Name</th>
<th>Priority</th>
<th>Status</th>
</tr>
</thead>
</table>
@section Scripts {
<script src="~/areas/Main/Todo/Views/Index.cshtml.js" asp-append-version="true"></script>
}
Razor handles server-side concerns — layout selection, asset versioning, rendering sections — and then hands over to the browser. The collocated Index.cshtml.js file is loaded alongside the view, which keeps a screen's markup and its behavior in one place instead of scattering scripts across the project. Part 7 examines that file closely.
Step 4 — Models and request types
There are two kinds of "model" in a healthy MVC feature, and they should not be the same class:
- Entity models describe how data is stored: table columns, relationships, keys.
- Request models (DTOs) describe what a client may send: a flat shape with only the properties an operation accepts.
The create operation accepts a request type that matches its form, including child items and attachments as simple strings and lists:
public class CreateTodoRequest
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsCompleted { get; set; }
public string? Tags { get; set; }
public string? DueDate { get; set; }
public TodoPriority? Priority { get; set; }
public decimal Progress { get; set; }
public TodoCategory? Category { get; set; }
public string? OwnerUserId { get; set; }
public List<CreateTodoItemRequest>? Items { get; set; }
}
The entity can change its storage details without breaking the API contract, and a client cannot set fields the request type does not expose — a simple but effective safety boundary.
Step 5 — EF Core, DbContext, and SQL
Entity Framework Core maps C# objects to SQL tables. A entity class describes the row; a DbContext exposes the sets and tracks changes:
public class Todo : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsCompleted { get; set; }
public TodoPriority? Priority { get; set; }
public decimal Progress { get; set; }
public string? OwnerUserId { get; set; }
public List<TodoItem>? Items { get; set; }
}
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Todo> Todo => Set<Todo>();
}
Reading data is LINQ. The list query below filters, sorts, projects only the columns the grid needs, and returns a page of results — the pattern used by real business tables:
var query = _context.Todo.Include(x => x.OwnerUser);
if (!string.IsNullOrWhiteSpace(request.Search))
query = query.Where(x => x.Name != null
&& x.Name.ToLower().Contains(request.Search.ToLower()));
var items = await query
.OrderByDescending(x => x.CreatedAt)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.Select(x => new TodoListItem
{
Id = x.Id,
Name = x.Name,
Priority = x.Priority,
Progress = x.Progress,
OwnerEmail = x.OwnerUser != null ? x.OwnerUser.Email : null
})
.ToListAsync(cancellationToken);
Write operations load the entity, mutate it, and call SaveChangesAsync. EF Core turns the change tracking into the appropriate UPDATE statement, inside a transaction, against whichever database provider the application is configured with.
Step 6 — Handling the form submission
The Create screen gathers fields in the browser and posts them as JSON. The collocated script owns that contract:
var response = await fetch('/api/todo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
var result = await response.json();
if (result.success) {
window.showToast('success', 'Todo Created',
'Todo "' + result.data.name + '" has been created.');
}
On the server, a Minimal API endpoint receives the same request type from Step 4, delegates to a handler, and translates the handler result into an HTTP status — 201 Created on success, 400 Bad Request when validation fails. Part 7 shows the endpoint and handler line by line.
Step 7 — Validation
Validation rules live with the use case, not with the storage entity. This FluentValidation validator is invoked inside the create handler before anything is saved:
public class CreateTodoValidator : AbstractValidator<CreateTodoRequest>
{
public CreateTodoValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Todo Name is required")
.MaximumLength(200).WithMessage("Todo Name must not exceed 200 characters");
RuleFor(x => x.Progress)
.InclusiveBetween(0, 100).WithMessage("Todo Progress must be between 0 and 100");
RuleFor(x => x.Priority)
.IsInEnum().WithMessage("Todo Priority must be a valid value");
}
}
The handler returns a structured failure — field messages plus a summary — that the browser renders next to the offending inputs. Client-side checks are a convenience; this server-side validation is the contract.
Step 8 — Verify the whole loop
A practical exercise closes the tutorial. In a local copy:
- Put a breakpoint in the
Indexaction and open the list page — the page request alone reaches it. - Put a breakpoint in the create handler and submit the form — this time the request arrives through
/api/todo, not through an MVC action. - Remove the
namefield from the JSON in an API client and resubmit — the server should reject it with validation errors even though the browser would normally prevent the submission. - Check the browser Network panel: a successful create is
201, a validation failure is400, and both show the same response envelope.
Microsoft's own tutorial covers the same concepts in more depth across several pages: start with Get started with ASP.NET Core MVC, then continue with adding a controller, adding a view, adding a model, and working with SQL.
Key Takeaways
- A controller action selects a screen; data operations belong to endpoints and handlers.
- Route templates connect URLs to actions; page routes and API routes are separate surfaces.
- Keep storage entities and request DTOs apart so contracts stay stable.
- EF Core turns LINQ queries and change-tracked entities into SQL through
DbContext. - Validation belongs to the use case and must run on the server, regardless of client-side checks.
FAQ
Do I need Entity Framework Core to use ASP.NET Core MVC?
No — MVC works with any data access technology. EF Core is the standard choice in modern .NET because it maps cleanly to C# models and supports multiple database providers.
Should controllers talk to the DbContext directly?
For a first prototype it works, but as soon as an action does validation, mapping, and persistence at once, the controller becomes untestable. Handlers keep those concerns out of the controller — see Part 8.
Where should validation live: the entity or the request?
Storage constraints belong to the entity and database configuration; input rules belong to request validators that run inside the use case. The two overlap but are not identical.
Can I use an MVC page form without JavaScript?
Yes. Classic MVC posts the form to another action. The API-based flow in this tutorial keeps the page and the write operation independent, which scales better as screens get richer.
