VSAMVCTutorialSeptember 2026 · 8 min read

Tutorial Vertical Slice Architecture VSA in ASP.NET Core MVC — Part 1: Project Setup & First Slice

TL;DR

Part 1 starts the Tutorial VSA in ASP.NET Core MVC series. We create a new .NET 10 MVC project, use Areas as the VSA backbone, build the per-feature folder structure under Areas/Main/Todo (Controllers, Cqrs, Endpoints, Views), wire plain CQRS handlers with no MediatR, add a thin role-protected controller, render the first Todo list with DataTables.js, and expose the same data through a minimal-API endpoint group — all from real MVC Project Manager code.

Part 1 of 2 in the VSA in ASP.NET Core MVC Tutorial Series|Next: Part 2 →

Help Us Grow

Love this tutorial? Explore our ready-to-use enterprise starter kits built with VSA in .NET 10.

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.

Key Takeaways

  • MVC uses Areas as the VSA backbone — each feature lives under Areas/Main/<Feature> with Controllers, Cqrs, Endpoints, and Views subfolders
  • Plain handlers with HandleAsync returning ApiResponse<T> replace MediatR — no pipeline, no IRequest, no extra packages
  • The controller stays thin: [Authorize(Roles)] plus returning Razor views only
  • DataTables.js drives the list with server-side parameters (search/start/length) mapped into a DataTableRequest
  • Minimal-API endpoint groups live inside MVC apps, sharing handlers and role authorization

Frequently Asked Questions

Q: Can ASP.NET Core MVC use Vertical Slice Architecture?

Yes, and it's a natural fit. MVC already separates concerns per feature via controllers and areas. VSA adds the convention that everything for a feature — controllers, CQRS handlers, endpoints, and views — lives in one folder. The MVC Project Manager is a production MVC app built entirely this way.

Q: Areas or plain feature folders in MVC VSA?

Use Areas. They give you clean URL structure (/Main/Todo/...), automatic view discovery under Areas/Main/Todo/Views, and a logical module boundary. Plain folders under a root Features/ directory work too, but then you must configure view locations and routes manually. MVC Project Manager uses the Main area.

Q: Controllers or Minimal APIs in MVC VSA?

Both, for different jobs. Controllers are the page router — they return Razor views for human users. Minimal-API endpoint groups are the data API — they return JSON for DataTables, Vue.js, and other clients. Both are thin and call the same handlers, so there's no logic duplication.

Q: Do I need MediatR in MVC VSA?

No. MVC Project Manager builds handlers as plain classes with a HandleAsync method and constructs them directly in controllers and endpoints. You get CQRS without the mediator. Choose MediatR only if you want its pipeline behaviors (automatic validation, logging) and are comfortable with the extra abstraction.

Part 1 of 2 in the VSA in ASP.NET Core MVC Tutorial Series|Next: Part 2 →

Ready to study real VSA code?

Every Indotalent product is a complete .NET 10 application built with Vertical Slice Architecture. Complete source code — $21 each.

Explore Products

Looking for a ready-to-use traditional monolithic multilayered clean architecture?

Monolithic Clean Architecture — 1,300+ devs, 500+ forks. Clean Arch + CQRS + Repository Pattern. Free & open source for commercial use.

Star on GitHub