This is the first part of a beginner-friendly, step-by-step tutorial series on Vertical Slice Architecture (VSA) in Blazor. If you're used to architecture articles full of boxes and arrows, this series is the opposite: we create a real project, one step at a time, and you can follow along with dotnet on your own machine. Everything you build here comes from patterns used in a real production Blazor CRM application — the same folder structure, the same handler shape, the same component patterns.
By the end of Part 1, you'll have a running Blazor Server application in .NET 10 with one complete, read-only vertical slice: a Todo list that reads from a database through a MediatR handler and renders in a MudBlazor table. That might not sound like much, but the important thing is where each piece lives. Once you see how one slice is organized, every future feature follows the same path.
Step 1: Create the Blazor Server Project
Open a terminal and create a new .NET 10 Blazor project:
dotnet new blazor -n VsaBlazorTodo cd VsaBlazorTodo dotnet run
Take a look at the default structure — you'll see Program.cs, the App.razor root component, and folders like Components and Layout. There is no Controllers, no Services, no Repositories. In VSA we keep it that way: instead of organizing by technical layer, we organize by feature.
Step 2: Add the NuGet Packages
Our first slice needs MediatR for the command/query pipeline, EF Core for data access, and MudBlazor for the UI:
dotnet add package MediatR dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package FluentValidation dotnet add package MudBlazor
You'll use FluentValidation in Part 2 when we add the create and update slices. For now these packages are everything needed to define an entity, query it, and display it.
Step 3: Build the VSA Feature-Folder Structure
The heart of VSA is the feature folder — one folder per feature containing everything that feature needs. Create this structure:
VsaBlazorTodo/
└── Features/
└── Utilities/
└── Todo/
├── Cqrs/
│ ├── GetTodoListHandler.cs
│ ├── CreateTodoHandler.cs
│ ├── UpdateTodoHandler.cs
│ └── DeleteTodoByIdHandler.cs
├── Components/
│ ├── TodoPage.razor
│ └── _TodoDataTable.razor
├── TodoService.cs
└── TodoEndpoint.cs
The Cqrs folder holds commands, queries, and handlers. The Components folder holds Blazor components. TodoService.cs is the HTTP client wrapper, and TodoEndpoint.cs maps the REST routes. This mirrors Blazor CRM, where the feature is named Utilities/Todo because it's a cross-cutting utility module. You don't need separate projects for "domain" or "infrastructure" — the slice is the boundary.
Step 4: Define the Todo Entity
In VSA, the entity lives with the feature. Create Features/Utilities/Todo/Todo.cs:
namespace VsaBlazorTodo.Features.Utilities.Todo;
public class Todo
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string? AutoNumber { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public DateTime? StartTime { get; set; }
public DateTime? EndTime { get; set; }
public bool IsCompleted { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public string? CreatedBy { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}
Note the audit fields — CreatedAt, CreatedBy, UpdatedAt, UpdatedBy. Blazor CRM keeps these on nearly every entity and fills them in with a shared save interceptor rather than in each handler. The fields exist on the model now so query projections can expose them.
Step 5: Wire EF Core and MediatR in Program.cs
Register the DbContext and MediatR in Program.cs. MediatR scans the assembly for handlers, so one registration line covers every future slice:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// ...after app is built
app.MapTodoEndpoints();
Because MediatR scans the whole assembly, adding a new IRequestHandler<,> later requires zero changes to Program.cs. That's the "open for extension, closed for modification" benefit VSA gets almost for free.
Step 6: The First Slice — GetTodoList Handler
Now the core of the slice: a query record and a handler implementing IRequestHandler<,>. The handler takes only the DbContext as a dependency and projects entities straight into response DTOs:
public record GetTodoListQuery() : IRequest<List<GetTodoListResponse>>;
public class GetTodoListHandler : IRequestHandler<GetTodoListQuery, List<GetTodoListResponse>>
{
private readonly AppDbContext _context;
public GetTodoListHandler(AppDbContext context) => _context = context;
public async Task<List<GetTodoListResponse>> Handle(
GetTodoListQuery request, CancellationToken cancellationToken)
{
return await _context.Todo
.AsNoTracking()
.OrderByDescending(x => x.CreatedAt)
.Select(x => new GetTodoListResponse
{
Id = x.Id,
AutoNumber = x.AutoNumber,
Name = x.Name,
Description = x.Description,
StartTime = x.StartTime,
EndTime = x.EndTime,
IsCompleted = x.IsCompleted,
CreatedAt = x.CreatedAt,
CreatedBy = x.CreatedBy,
UpdatedAt = x.UpdatedAt,
UpdatedBy = x.UpdatedBy
})
.ToListAsync(cancellationToken);
}
}
Notice what's not here: no service class, no repository, no mapping layer. The projection happens inline with a LINQ Select, so EF Core translates it directly to SQL. This is the query pattern used in the real GetTodoListHandler from Blazor CRM, simplified for the tutorial.
Step 7: Expose It with an Endpoint
Add a static endpoint class that maps a route group. The real Blazor CRM group is MapGroup("/todo").RequireAuthorization(...JwtBearer); here we use /api/todo and keep the same shape:
public static class TodoEndpoint
{
public static void MapTodoEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/todo").WithTags("Todos")
.RequireAuthorization(policy => policy
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
group.MapGet("/", async (IMediator mediator) =>
{
var result = await mediator.Send(new GetTodoListQuery());
return Results.Ok(result);
})
.WithName("GetTodoList");
}
}
Calling mediator.Send(new GetTodoListQuery()) dispatches to the handler registered in Step 5. For a tutorial you can comment out the RequireAuthorization line until authentication is wired up in a later part — the route-group shape stays the same either way.
Step 8: The Service Layer
Blazor components shouldn't call HttpClient directly. A small service wraps the API calls. Blazor CRM's TodoService extends a BaseService and uses RestSharp; the essential shape is:
public class TodoService
{
private readonly RestClient _client;
public TodoService(NavigationManager nav)
{
_client = new RestClient(nav.BaseUri);
}
public async Task<ApiResponse<List<GetTodoListResponse>>?> GetTodoListAsync()
{
var request = new RestRequest("api/todo", Method.Get);
return await _client.ExecuteAsync<ApiResponse<List<GetTodoListResponse>>>(request);
}
}
The ApiResponse<T> wrapper is a standard envelope that carries Success, Message, and Data. Every endpoint returns it, which gives the front end a uniform way to handle success and failure — more on that in Part 2.
Step 9: Render the Slice with MudBlazor
Finally, the read-only page. Create TodoPage.razor with a MudBlazor MudTable:
@page "/todo"
@using VsaBlazorTodo.Features.Utilities.Todo
@using VsaBlazorTodo.Features.Utilities.Todo.Cqrs
@using MudBlazor
<MudTable Items="_todos" ReadOnly Bordered Dense>
<HeaderContent>
<MudTh>Auto Number</MudTh>
<MudTh>Name</MudTh>
<MudTh>Start</MudTh>
<MudTh>End</MudTh>
<MudTh>Status</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.AutoNumber</MudTd>
<MudTd>@context.Name</MudTd>
<MudTd>@context.StartTime</MudTd>
<MudTd>@context.EndTime</MudTd>
<MudTd>@(context.IsCompleted ? "Completed" : "Pending")</MudTd>
</RowTemplate>
</MudTable>
@code {
private List<GetTodoListResponse> _todos = new();
protected override async Task OnInitializedAsync()
{
var response = await TodoService.GetTodoListAsync();
if (response?.Success == true) _todos = response.Data ?? new();
}
}
That's the whole first slice: query record, handler, endpoint, service, and component. In Blazor CRM this page is a state machine that switches between table, create, update, and view modes. Part 2 turns our read-only page into that complete CRUD experience.
Why Feature Folders Beat Layer Folders for Beginners
The main reason this structure is great for learning is traceability. When a bug is in "the todo list," you open one folder and see everything involved — the SQL projection, the route, the HTTP call, and the markup. Nothing lives two folders away. When you add a feature in Part 2, you'll extend this same skeleton, which is exactly how real VSA codebases grow.