Part 6 built the MVC loop step by step. This part does something different: it takes one complete, production-shaped feature — the Todo module from a real business application — and walks it end to end in the order an experienced developer would read it. The goal is pattern recognition: after this, opening an unfamiliar feature of any well-structured MVC application should feel familiar.
The feature at a glance
Todo is a task-management module with four screens: a searchable list, a create form, an edit form, and a read-only detail page. A todo has a name, description, tags, priority, category, progress, a due date and time, a completion flag, an owner, child checklist items, and image and file attachments.
Everything for the feature lives under one area folder:
Areas/Main/Todo/
├── Controllers/
│ └── TodoController.cs // page routing only
├── Cqrs/
│ ├── GetTodoListHandler.cs // queries
│ ├── GetTodoByIdHandler.cs
│ ├── GetTodoItemsHandler.cs
│ ├── CreateTodoHandler.cs // commands
│ ├── CreateTodoValidator.cs
│ ├── UpdateTodoHandler.cs
│ ├── UpdateTodoValidator.cs
│ ├── DeleteTodoHandler.cs
│ └── ... // export, attachments, user lookup
├── Endpoints/
│ └── TodoEndpoint.cs // /api/todo route group
└── Views/
├── Index.cshtml + Index.cshtml.js
├── Create.cshtml + Create.cshtml.js
├── Edit.cshtml + Edit.cshtml.js
└── Detail.cshtml + Detail.cshtml.js
The folder names are not decoration — they are the architecture. Controllers render pages, Cqrs explains what the feature can do, Endpoints expose those use cases over HTTP, and Views own everything the browser sees, including behavior.
Two request paths, one feature
The most important idea in this module is that a page and its data travel separately. Two diagrams capture the whole application:
Page path (HTML rendering)
Browser
│ GET /Main/Todo/Index
▼
TodoController ──> View("~/Areas/Main/Todo/Views/Index.cshtml")
│
▼
Razor view renders HTML ──> browser executes Index.cshtml.js
Data path (JSON operations)
Browser
│ POST /api/todo (JSON body)
▼
TodoEndpoint ──> CreateTodoHandler ──> CreateTodoValidator
│ │
│ ▼
│ AppDbContext ──> SQL
▼
ApiResponse<T> ──> browser updates the screen
Neither path can block the other. The page loads instantly with its shell and script; the script then fetches data from the API. A validation failure never turns into a broken page, and a new UI can reuse the same endpoints without touching the server code.
Step 1 — The page controller
The controller is deliberately small. It maps URLs to views and carries identifiers through ViewBag when a screen needs one:
[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");
}
}
The [Authorize] attribute protects the whole controller; the API has its own, equivalent rule. No action queries a database, so no action needs a test database to verify routing.
Step 2 — The view and its collocated script
Each view pairs with a JavaScript file of the same name. The Create screen's script owns the JSON contract: it collects the form into a payload and posts it to the endpoint:
var payload = {
name: form.name,
description: form.description || null,
isCompleted: form.isCompleted,
tags: form.tags || null,
dueDate: form.dueDate || null,
priority: form.priority || null,
progress: form.progress,
category: form.category || null,
ownerUserId: form.ownerUserId || null,
items: form.items.map(function(item) {
return {
name: item.name,
isCompleted: item.isCompleted,
assignedToUserId: item.assignedToUserId,
startDate: item.startDate,
startTime: item.startTime
};
}),
imageAttachments: form.imageAttachments.map(function(img) {
return { id: img.id || null, fileName: img.fileName, data: img.data || null };
}),
fileAttachments: form.fileAttachments.map(function(file) {
return { id: file.id || null, fileName: file.fileName, data: file.data || null };
})
};
var response = await fetch('/api/todo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
The response handling is equally explicit: the script reads result.success, result.data, and result.errors — properties of the application's ApiResponse<T> envelope, not built-in MVC response fields — and maps field errors back onto the form.
Step 3 — The endpoint
The API surface is one file with one route group. Every route requires the same roles as the controller, and handlers are injected directly — no dispatcher, no service locator:
public static class TodoEndpoint
{
public static void MapTodoEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/todo")
.WithTags("Todos")
.RequireAuthorization(new AuthorizeAttribute
{
Roles = $"{ApplicationRoles.AdminConst},{ApplicationRoles.MemberConst}"
});
// Literal routes must be registered before /{id}
group.MapGet("/export", async (HttpContext httpContext,
GetTodoExportHandler handler) =>
{
var search = httpContext.Request.Query["search"].FirstOrDefault();
var result = await handler.HandleAsync(search, httpContext.RequestAborted);
return Results.Ok(result);
}).WithName("ExportTodo");
// GET /api/todo/{id}
group.MapGet("/{id}", async (string id, GetTodoByIdHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(id, ct);
return result.Success ? Results.Ok(result) : Results.NotFound(result);
}).WithName("GetTodoById");
// POST /api/todo
group.MapPost("/", async (CreateTodoRequest request,
CreateTodoHandler handler, CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return result.Success
? Results.Created($"/api/todo/{result.Data?.Id}", result)
: Results.BadRequest(result);
}).WithName("CreateTodo");
}
}
Three details carry real weight. Routes with literal segments (/export, /user-lookup) are registered before /{id} so they cannot be captured as an id. Endpoints only translate handler results into HTTP — they contain no business logic. And authentication is declared once on the group, so no endpoint can forget it.
Step 4 — The command handler
A handler owns one use case from start to finish. The create handler validates, prepares attachment payloads, builds the entity graph, saves, and returns a typed result:
public class CreateTodoHandler
{
private readonly AppDbContext _context;
private readonly FileStorageService _fileStorageService;
public CreateTodoHandler(AppDbContext context,
FileStorageService fileStorageService)
{
_context = context;
_fileStorageService = fileStorageService;
}
public async Task<ApiResponse<CreateTodoResponse>> HandleAsync(
CreateTodoRequest request, CancellationToken cancellationToken = default)
{
// 1. Validate
var validator = new CreateTodoValidator();
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
return ApiResponse<CreateTodoResponse>.Fail("Validation failed",
validationResult.ToDictionary());
// 2. Decode attachment payloads up-front so no file is orphaned later
// 3. Build the entity, including child items
var entity = new Data.Entities.Todo
{
Name = request.Name,
Description = request.Description,
IsCompleted = request.IsCompleted,
Tags = request.Tags,
Priority = request.Priority,
Progress = request.Progress,
Category = request.Category,
OwnerUserId = request.OwnerUserId,
Items = request.Items?.Select(i => new TodoItem
{
Name = i.Name,
IsCompleted = i.IsCompleted,
AssignedToUserId = i.AssignedToUserId
}).ToList()
};
// 4. Save files and attachment records, then persist
_context.Todo.Add(entity);
await _context.SaveChangesAsync(cancellationToken);
return ApiResponse<CreateTodoResponse>.Ok(
new CreateTodoResponse { Id = entity.Id, Name = entity.Name },
"Todo has been created successfully");
}
}
The handler is the only place in the feature that knows the rules of creating a todo. That makes it the natural unit to test: give it a request, a context, and a fake storage service, and assert the result.
Step 5 — The query side
Queries get the same treatment. The list handler translates table parameters into an EF Core query, filters across name, description, tags, priority, category, auto number, and owner, applies the requested sort, projects directly into a lightweight list item, and returns a paged result. The projection matters: the grid asks for ten columns, and the query retrieves only those columns.
Read models are also where a real application handles awkward translation problems — for example, matching a search term against enum names requires filtering the enum values in memory first, because EF Core cannot translate Enum.ToString() to SQL. That kind of detail is exactly why queries deserve their own classes instead of squeezing into controllers.
How to read an unfamiliar feature
When you open a feature you did not write — or an AI assistant opens it to extend it — follow the requests, not the folders:
- Start at the controller: what pages exist, and what are their URLs?
- Open each view and its collocated script: what data does the screen fetch, and from which endpoint?
- Read the endpoint file: what does each route accept and return?
- Read the handlers in
Cqrs/: what use cases exist, and what rules do they enforce? - Read the validators last: they are the exact contract for each request.
Five file kinds, always in the same places. That predictability is what distinguishes an architecture from a pile of code — and it is why the next part, Why ASP.NET Core MVC Controllers Become Bloated, treats controller growth as an architectural signal rather than a code-style complaint.
Key Takeaways
- A real MVC feature is two request paths: page rendering and data operations.
- Controllers route pages; endpoints expose use cases; handlers own rules; views own presentation.
- Collocated
.cshtml.jsfiles keep each screen's behavior beside its markup. - Handlers return typed results that endpoints translate into HTTP status codes.
- Consistent file kinds in consistent places make a feature readable by humans and extendable by AI.
FAQ
Why not put the query in the controller action?
You can for a prototype, but the list screen needs filtering, sorting, paging, enum translation, and projection. That is a use case, and use cases deserve a class that can be tested and reused by both the page and the API.
Is this CQRS?
It is CQRS in its practical form: commands and queries are separate classes with different shapes. It does not require a message bus, separate databases, or event sourcing.
Why does the endpoint construct the handler directly?
Minimal API endpoints can inject handler classes from DI directly, which keeps the slice self-contained and avoids dispatcher indirection. Part 8 covers when a dispatcher such as MediatR is still worth it.
How does authentication apply to both surfaces?
The controller carries an [Authorize] attribute; the endpoint route group declares the same roles. Both are enforced by the same ASP.NET Core authorization system.
