Building a complete application with Vertical Slice Architecture (VSA) starts with getting the project structure right. In this first part of the VSA Todo App tutorial series, we'll create a new .NET 10 project, set up the folder conventions that make VSA work, configure the database and MediatR, define our Todo entity, and implement the first feature slice — listing all todos. Everything we build here comes from real production code in the Blazor CRM and MVC Project Manager applications, both of which ship complete VSA Todo features.
The key insight of VSA is that every feature should be self-contained. Instead of scattering code across Controllers, Services, Repositories, and Models folders, VSA puts everything a feature needs — its endpoints, handlers, DTOs, validators, and views — inside a single feature folder. When you need to modify the Todo feature, you open one folder. When you add a new feature, you add one folder. This is the exact pattern used in every Indotalent product.
Creating the Project
Create a new ASP.NET Core Web API project targeting .NET 10. Add the NuGet packages you'll need: MediatR, Microsoft.EntityFrameworkCore.SqlServer, and FluentValidation. The project is a single-project monolith — one .csproj file, one Program.cs. VSA works best with fewer projects because it eliminates cross-project references and keeps feature boundaries inside code organization rather than assembly boundaries.
The VSA Folder Structure
The folder structure is the backbone of VSA. Create a Features/ folder at the project root. Inside it, create Features/Utilities/Todo/. This is your first vertical slice. Every file related to the Todo feature lives here. The structure follows this pattern, taken directly from the Blazor CRM production codebase:
Features/Utilities/Todo/
├── Cqrs/ # Commands, queries, handlers, validators
│ ├── CreateTodoHandler.cs
│ ├── UpdateTodoHandler.cs
│ ├── GetTodoListHandler.cs
│ ├── GetTodoByIdHandler.cs
│ ├── DeleteTodoByIdHandler.cs
│ ├── CreateTodoValidator.cs
│ └── UpdateTodoValidator.cs
├── Endpoints/
│ └── TodoEndpoint.cs # Minimal API route definitions
└── Components/ # Blazor UI components (or Views/ for MVC)
└── TodoPage.razor
The Cqrs/ folder contains all command and query handlers plus their validators. Each handler file defines its own request DTO, response DTO, and handler class — everything co-located in one file. The Endpoints/ folder maps these handlers to HTTP routes. The Components/ folder (or Views/ for MVC) holds the UI. This is the structure used in production across the Blazor CRM Todo feature.
Defining the Todo Entity
The Todo entity is a standard EF Core entity. Create it at Data/Entities/Todo.cs (shared across features):
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; }
public List<TodoItem> TodoItemList { get; set; } = new();
}
Notice the audit fields — CreatedAt, CreatedBy, UpdatedAt, UpdatedBy. These are automatically populated by the DbContext and are essential for production applications. The TodoItemList navigation property sets up the parent-child relationship we'll explore in Part 6.
Configuring the DbContext
Add a DbSet<Todo> to your AppDbContext and configure it in OnModelCreating. Register the DbContext in Program.cs with your SQL Server connection string. Register MediatR with builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<Program>()). This is all standard .NET setup — VSA doesn't require special infrastructure.
Implementing the First Feature Slice: List Todos
The first slice we build is the simplest: listing all todos. Create Features/Utilities/Todo/Cqrs/GetTodoListHandler.cs. In VSA, the handler file contains the query, the response DTO, and the handler class — all in one place:
// GetTodoListHandler.cs — one file, one feature operation
public class GetTodoListResponse
{
public string? Id { get; set; }
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; }
}
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);
}
}
This is the entire List Todos feature in one file. The GetTodoListQuery record is the input, the GetTodoListResponse class is the output DTO, and the handler performs the database query with AsNoTracking() for read-only performance and Select projection to avoid loading unnecessary columns. No repository layer, no service layer — just the query and the data it needs.
Mapping the Endpoint
Create Features/Utilities/Todo/Endpoints/TodoEndpoint.cs to expose the handler as an HTTP endpoint:
public static class TodoEndpoint
{
public static void MapTodoEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/todo")
.RequireAuthorization(policy => policy
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
group.MapGet("/", async (IMediator mediator) =>
{
var result = await mediator.Send(new GetTodoListQuery());
return Results.Ok(result);
});
}
}
Call app.MapTodoEndpoints() in Program.cs. The endpoint is now live at GET /api/todo. The MapGroup with RequireAuthorization protects all Todo endpoints with JWT authentication in one line — every endpoint under this group inherits the auth requirement automatically.
What We Built and What's Next
In Part 1, we created the project, set up the VSA folder structure, defined the Todo entity, configured EF Core and MediatR, and implemented our first working feature slice — listing todos via a Minimal API endpoint. The project compiles, the endpoint works, and the architecture is clean. In Part 2, we'll add Create, Update, and Delete command handlers and explore the two CQRS patterns used in production: MediatR-based handlers from Blazor CRM and manual handler classes from MVC Project Manager.