Vertical Slice Architecture is usually associated with minimal-API projects, but it works just as well — and arguably even more naturally — in ASP.NET Core MVC. MVC already gives you a controller per feature; VSA just tightens the boundary by pulling the handlers, endpoints, and views into the same feature folder. This tutorial series walks through building a real MVC application in .NET 10, one step at a time, using the exact patterns from the MVC Project Manager codebase.
By the end of Part 1, you'll have a working MVC app with the Areas-based VSA folder structure, a plain CQRS handler (no MediatR) that powers a server-side DataTables list, a controller protected by role authorization, and a minimal-API endpoint group that exposes the same data as JSON.
Step 1: Create the MVC Project
Create a new .NET 10 MVC project:
dotnet new mvc -n VsaMvcTodo cd VsaMvcTodo dotnet run
The template gives you Program.cs, Controllers/, Views/, and wwwroot/. In VSA we're going to reorganize: features become the top-level unit, and each feature keeps its controllers, handlers, endpoints, and views together.
Step 2: Set Up Areas and the Feature Folder
ASP.NET Core Areas are the perfect backbone for VSA. Add an area named Main, then create the Todo feature folder inside it:
dotnet aspnet-codegenerator area Main
Build this structure:
VsaMvcTodo/
└── Areas/
└── Main/
└── Todo/
├── Controllers/
│ └── TodoController.cs
├── Cqrs/
│ ├── GetTodoListHandler.cs
│ ├── CreateTodoHandler.cs
│ ├── UpdateTodoHandler.cs
│ └── DeleteTodoHandler.cs
├── Endpoints/
│ └── TodoEndpoint.cs
└── Views/
├── Index.cshtml
├── Create.cshtml
├── Edit.cshtml
└── Detail.cshtml
Each subfolder has a single responsibility, but they all belong to the Todo feature. MVC Project Manager uses exactly this shape: Areas/Main/Todo/Controllers, Cqrs, Endpoints, and Views. Controllers handle page requests, Cqrs holds plain handlers, Endpoints exposes the REST API, and Views hold the Razor templates.
Step 3: The Plain Handler Pattern (No MediatR)
The most distinctive choice in MVC VSA is skipping MediatR entirely. Handlers are plain classes with a HandleAsync method that returns an ApiResponse<T> envelope. Here's the list handler, which accepts a DataTableRequest and returns a paginated result:
public class GetTodoListHandler
{
private readonly AppDbContext _context;
public GetTodoListHandler(AppDbContext context) => _context = context;
public async Task<ApiResponse<object>> HandleAsync(
DataTableRequest request, CancellationToken cancellationToken = default)
{
var query = _context.Todo.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.Search))
{
var search = request.Search.ToLower();
query = query.Where(x =>
(x.Name != null && x.Name.ToLower().Contains(search)) ||
(x.AutoNumber != null && x.AutoNumber.ToLower().Contains(search)));
}
query = query.OrderByDescending(x => x.CreatedAt);
return await query
.Select(x => new TodoListItem
{
Id = x.Id,
AutoNumber = x.AutoNumber,
Name = x.Name,
Priority = x.Priority,
Progress = x.Progress
})
.ToDataTableAsync(request, "Todo list retrieved successfully", cancellationToken);
}
}
There's no IRequest, no pipeline, no mediator. The caller — a controller or an endpoint — just constructs the handler and calls HandleAsync. This is the "direct handlers" pattern from MVC Project Manager, and it's the lowest-friction way to do CQRS.
Step 4: The Controller
The controller is deliberately thin. It belongs to the Main area, requires the Admin or Member role, and just returns views:
[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 that the controller contains no data-access logic. It routes page requests to views; the data flows through the handlers and the minimal-API endpoints. MVC's role-based [Authorize(Roles)] protects the pages, and the API group protects the JSON routes with the same roles.
Step 5: The Razor View with DataTables.js
The Index view declares a plain HTML table and lets DataTables.js drive it with server-side processing. The interactive layer is a Vue.js-powered script that calls the API:
@{
ViewData["Title"] = "Todo Management";
Layout = "/Areas/_LayoutArea.cshtml";
}
<div id="app-index" v-cloak>
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<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>Progress</th>
<th>Status</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
@section Scripts {
<script src="~/areas/Main/Todo/Views/Index.cshtml.js" asp-append-version="true"></script>
}
DataTables sends search[value], start, length, and order parameters with each request — the same parameters the DataTableRequest in the handler is designed to receive. The Vue.js side mounts on #app-index and initializes the table. This split — Razor for the page shell, Vue.js for the interactive table — is the MVC Project Manager convention we keep in this series.
Step 6: The Minimal-API Endpoint Group
MVC apps can host minimal APIs too. The Todo endpoint group maps /api/todo, applies the same role authorization, and calls the handlers directly:
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}"
});
group.MapGet("/", async (HttpContext httpContext, AppDbContext db, CancellationToken ct) =>
{
var query = httpContext.Request.Query;
var request = new DataTableRequest
{
Search = query.ContainsKey("search[value]")
? query["search[value]"].FirstOrDefault()
: query["search"].FirstOrDefault(),
Start = int.TryParse(query["start"].FirstOrDefault(), out var start) ? start : 0,
Length = int.TryParse(query["length"].FirstOrDefault(), out var length) ? length : 10
};
var handler = new GetTodoListHandler(db);
var result = await handler.HandleAsync(request, ct);
return result.Success ? Results.Ok(result) : Results.BadRequest(result);
}).WithName("GetTodoList");
}
}
Register the group in Program.cs after the app is built:
app.MapTodoEndpoints();
The handler is constructed right in the endpoint lambda — no DI ceremony for a class with a single DbContext dependency. If a handler ever grows extra dependencies, register it with the DI container and request it by parameter instead.
Controllers vs Minimal APIs in VSA
You now have both paths working side by side: the controller returns HTML pages for humans, and the endpoint group returns JSON for the DataTables/Vue.js front end. This is a common MVC VSA setup — the controller is the page router, the endpoints are the data API. Both are thin, both call the same handlers, and both stay inside the Todo feature folder.