Endpoints are the entry points to your VSA feature slices. In VSA, each feature owns its endpoints — they live in the feature folder, not in a global Program.cs or a shared routes file. This means adding a feature adds endpoints, and removing a feature removes them cleanly. Both the Blazor CRM and MVC Project Manager follow this pattern, though they take different approaches to endpoint registration.
The Blazor CRM uses pure Minimal APIs with MapGroup for route organization. The MVC Project Manager uses a hybrid approach: MVC controllers for Razor views plus Minimal APIs for REST endpoints. Both patterns coexist in the same feature folder, and both demonstrate how VSA keeps endpoint definitions close to their handlers.
MapGroup: One Route Prefix for the Entire Feature
The MapGroup method is the backbone of VSA endpoint organization. It creates a route group with a shared prefix, shared authorization, and shared OpenAPI tags. The Blazor CRM Todo endpoint uses it to group all Todo routes under /api/todo:
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");
group.MapGet("/{id}", async (string id, IMediator mediator) =>
{
var result = await mediator.Send(new GetTodoByIdQuery(id));
return result is not null ? Results.Ok(result) : Results.NotFound();
}).WithName("GetTodoById");
group.MapPost("/", async (CreateTodoRequest request, IMediator mediator) =>
{
var result = await mediator.Send(new CreateTodoCommand(request));
return Results.Created($"/api/todo/{result.Id}", result);
}).WithName("CreateTodo");
group.MapPost("/update", async (UpdateTodoRequest request, IMediator mediator) =>
{
var result = await mediator.Send(new UpdateTodoCommand(request));
return result.Success ? Results.Ok(result) : Results.NotFound();
}).WithName("UpdateTodo");
group.MapPost("/delete/{id}", async (string id, IMediator mediator) =>
{
var result = await mediator.Send(new DeleteTodoByIdCommand(
new DeleteTodoByIdRequest(id)));
return result ? Results.Ok() : Results.NotFound();
}).WithName("DeleteTodoById");
}
}
Every endpoint under this group inherits JWT authentication automatically — no need to repeat [Authorize] on each route. The group also sets the OpenAPI tag to "Todos" so Swagger groups them together. Registration is a single line in Program.cs: app.MapTodoEndpoints().
Child Data Endpoints: Separate Routes for Items and Attachments
The MVC Project Manager goes further by exposing child data through dedicated endpoints. This allows the frontend to fetch items, image attachments, and file attachments independently without loading the entire Todo:
group.MapGet("/{id}/items", async (string id, IMediator mediator) =>
{
var todo = await mediator.Send(new GetTodoByIdQuery(id));
return todo is not null ? Results.Ok(todo.TodoItems) : Results.NotFound();
});
group.MapGet("/{id}/image-attachments", async (string id, ...) => { ... });
group.MapGet("/{id}/file-attachments", async (string id, ...) => { ... });
group.MapGet("/{id}/files/{attachmentId}/download", async (string id,
string attachmentId, ...) =>
{
var file = await fileStorage.GetFileAsync(attachmentId);
return Results.File(file.Stream, file.ContentType, file.FileName);
});
Separate child endpoints are essential for file-heavy features. The frontend can load the Todo details, then lazily load attachments only when the user expands that section. This keeps initial page loads fast and lets the browser cache attachment data independently.
Request Size Limits for File Uploads
When a feature accepts file uploads, the endpoint needs a request size limit. The MVC Project Manager sets a 100 MB limit on create and update endpoints:
[RequestSizeLimit(104857600)] // 100 MB
group.MapPost("/", async (CreateTodoRequest request, IMediator mediator) =>
{
var result = await mediator.Send(new CreateTodoCommand(request));
return Results.Created($"/api/todo/{result.Data.Id}", result);
});
Without this, file uploads could exhaust server memory or be rejected by Kestrel's default 30 MB limit. The limit is set per-endpoint, not globally, so file-heavy features get higher limits while lightweight endpoints stay restricted.
Hybrid MVC + Minimal API: Two Entry Points, One Feature
The MVC Project Manager demonstrates a powerful pattern: MVC controllers for Razor views coexisting with Minimal APIs for REST. The TodoController handles page navigation (Index, Create, Edit, Detail) while TodoEndpoint handles API calls. Both live in the same feature folder:
Areas/Main/Todo/ ├── Controllers/TodoController.cs ← MVC routes (pages) ├── Endpoints/TodoEndpoint.cs ← REST API routes ├── Cqrs/ ← shared handlers └── Views/ ← Razor views
The controller and endpoints call the same CQRS handlers. This means the business logic is written once, and both the page-based MVC flow and the AJAX-based API flow use it identically. The VSA folder structure makes this natural — both entry points are just files in the feature folder.
Endpoint Registration in Program.cs
Register all feature endpoints with extension methods. Each feature gets one line:
var app = builder.Build(); app.MapTodoEndpoints(); // Todo feature app.MapCurrencyEndpoints(); // Currency feature app.MapTaxEndpoints(); // Tax feature
This keeps Program.cs clean and makes feature registration explicit. Removing a feature is as simple as commenting out one line. The extension method pattern is the standard way to organize VSA endpoints in .NET 10.