.NET & C#
ASP.NET Core Minimal API Structure for Growing Apps
How to structure an ASP.NET Core minimal API on .NET 10: feature-based endpoint files, MapGroup, built-in validation, endpoint filters and DI.
Structure an ASP.NET Core minimal API by feature: give each feature a static class with a Map extension method, mount it on a MapGroup that carries the shared prefix, tags and authorization, keep handlers as small static methods that take services as parameters, and enable built-in validation so Program.cs stays pure composition.
Quick answer
- One folder per feature, with one static class exposing
MapXxxEndpoints(this IEndpointRouteBuilder)and oneAddXxx(this IServiceCollection)method. - Use
MapGroupfor the route prefix, tags, authorization and shared endpoint filters, instead of repeating them on every endpoint. - Write handlers as static methods, not inline lambdas, and return
TypedResultsso the response types are visible in the signature. - Call
AddValidation()and put DataAnnotations on request records. Use an endpoint filter only for rules that attributes cannot express. - Take services as handler parameters. Keep Program.cs limited to registering features and mapping them.
Assumptions and versions
Everything below targets .NET 10 (the current default version in the Microsoft Learn ASP.NET Core docs) and the Microsoft.NET.Sdk.Web SDK. It runs the same on Windows, macOS and Linux, since nothing depends on the host OS. The code is illustrative: it is a sketch of a layout, not a copy of a production system.
The sources I used are the Microsoft Learn pages for route handlers in minimal APIs, filters in minimal APIs, parameter binding and validation in ASP.NET Core.
Why Program.cs stops scaling
A minimal API starts with a handful of app.MapGet lambdas in one file. The trouble starts when that file also holds service registration, authentication setup, 30 endpoints, and the validation code for each. Nothing in the framework forces a structure on you, so the structure has to be a decision.
The docs state that route handlers can be a lambda, a local function, an instance method or a static method, and that endpoints do not have to live in Program.cs. That is the whole mechanism. Everything else here is convention built on top of it.
A feature-folder layout
Group code by feature rather than by technical layer. A change to orders should touch one folder.
src/Api/
Program.cs
Orders/
OrderEndpoints.cs // routes + handlers
OrderRequests.cs // request records with validation attributes
OrderService.cs // business logic
OrdersModule.cs // AddOrders() service registration
Customers/
...
Each feature exposes two extension methods. One registers its services, the other maps its routes:
// .NET 10, Microsoft.NET.Sdk.Web. Illustrative example.
namespace Api.Orders;
public static class OrdersModule
{
public static IServiceCollection AddOrders(this IServiceCollection services)
{
services.AddScoped<IOrderService, OrderService>();
return services;
}
}
// Program.cs (.NET 10). Illustrative example.
using Api.Orders;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
builder.Services.AddOrders();
var app = builder.Build();
app.MapOrderEndpoints();
app.Run();
Program.cs is now a table of contents. Adding a feature means adding two lines.
Use MapGroup for shared policy
MapGroup exists to reduce repetition: a common prefix, plus metadata and filters applied to the whole group with one call. The docs note that adding filters or metadata to a group behaves the same as adding them to each endpoint individually.
// .NET 10. Illustrative example.
using Microsoft.AspNetCore.Http.HttpResults;
namespace Api.Orders;
public static class OrderEndpoints
{
public static RouteGroupBuilder MapOrderEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/orders")
.WithTags("Orders")
.RequireAuthorization();
group.MapGet("/{id:int}", GetOrder).WithName("GetOrder");
group.MapPost("/", CreateOrder);
return group;
}
private static async Task<Results<Ok<OrderDto>, NotFound>> GetOrder(
int id, IOrderService orders, CancellationToken ct)
{
var order = await orders.FindAsync(id, ct);
return order is null ? TypedResults.NotFound() : TypedResults.Ok(order);
}
private static async Task<Created<OrderDto>> CreateOrder(
CreateOrderRequest request, IOrderService orders, CancellationToken ct)
{
var created = await orders.CreateAsync(request, ct);
return TypedResults.Created($"/orders/{created.Id}", created);
}
}
Three details are worth knowing:
- Endpoint names must be globally unique and case sensitive, and they become OpenAPI operation IDs. Prefix them with the feature if you have many.
- Groups nest. An outer group can add authorization while an inner group adds a version prefix.
- Filter order matters within one group or endpoint, but outer-group filters run before inner-group filters regardless of the order you call them.
Static handlers versus lambdas
Static methods give you stack traces with real names, handlers you can unit test by calling them directly, and a place to hang return types. Inline lambdas are fine for a health check. Once a handler has more than a few lines, name it.
Validation: built-in first, filters second
In .NET 10, minimal APIs can validate requests with the Microsoft.Extensions.Validation service. You register it once and declare rules with DataAnnotations. Per the docs, validation runs before the endpoint handler, and a failing request gets a 400 Bad Request with validation error details.
// .NET 10. Illustrative example.
using System.ComponentModel.DataAnnotations;
namespace Api.Orders;
public record CreateOrderRequest(
[Required, StringLength(80)] string CustomerName,
[Range(1, 1000)] int Quantity);
With builder.Services.AddValidation() in Program.cs, the handler above never sees an empty name or a quantity of zero.
Also from the docs: validation does not reject unmapped JSON properties (set JsonSerializerOptions.UnmappedMemberHandling to Disallow if you want that), and on .NET 10 there is a known limitation where nullable value types as parameters are not validated.
When to use an endpoint filter
Attributes cover field rules. For checks that need request context, such as "this order belongs to the caller's tenant", an IEndpointFilter fits. Attach it to a group so every endpoint in the feature gets it:
// .NET 10. Illustrative example.
public sealed class RequireTenantHeaderFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
if (!context.HttpContext.Request.Headers.ContainsKey("X-Tenant"))
{
return TypedResults.Problem("X-Tenant header is required.", statusCode: 400);
}
return await next(context);
}
}
// In MapOrderEndpoints:
// var group = routes.MapGroup("/orders").AddEndpointFilter<RequireTenantHeaderFilter>();
Two caveats from the filters page. A filter can take dependencies from DI in its constructor, but the filter itself cannot be resolved from DI. And filters do not run for body-reading failures such as malformed JSON, so do not rely on them as the only line of defense for the body.
| Concern | Best place | Why |
|---|---|---|
| Field format, length, range | DataAnnotations + AddValidation | Declarative, runs before the handler |
| Rules across several properties | IValidatableObject on the request | Runs after property validation |
| Per-request context (tenant, API version) | Endpoint filter on the group | Sees HttpContext and arguments |
| Uniqueness, existence checks | Service layer | Needs data access and a consistent transaction |
Dependency injection in handlers
Handler parameters whose type is registered as a service are resolved from the container. [FromServices] is optional, because DI is the fallback in the binding order after route, query, header, body and special types.
Practical rules that keep this tidy:
- Take interfaces that represent a use case (
IOrderService), not theDbContextand five repositories. Handlers should orchestrate, not contain business rules. - Register services in the feature's
AddXxxmethod so the feature owns its dependency graph. - If a route value cannot convert to the parameter type, binding throws
BadHttpRequestException. Use route constraints like{id:int}so bad paths fail at routing instead. - Do not pass
HttpContextinto the service layer. Pass the values you need, so services stay testable.
When minimal APIs are the wrong fit
If you depend on MVC conventions, such as heavy model binding customization, a large set of existing filters, or a team that already organizes work around controllers, moving to minimal APIs gains you little. Controllers and minimal endpoints can also coexist in the same app. The structure above is for teams that want explicit routing and small handlers, not a rule that controllers are obsolete.
Where this fits in production work
Endpoint layout is a small decision with long consequences: it decides how easy it is to review a change, add an integration, or hand the codebase to someone else. For webhook receivers, the same feature-folder approach applies, and the delivery-side design is covered in Webhooks vs polling: how to design a reliable integration.
I work with .NET among other stacks, and at Legacies I am Technical Lead, responsible for backend architecture, integrations and product stability. If you are weighing a restructure of a growing API, or commissioning custom backend work, the Legacies site and the selected work section describe what I take on, and the contact section is the way to start a conversation.
Frequently Asked Questions
Should I put all minimal API endpoints in Program.cs?
For a prototype or a service with a handful of routes, yes, that is fine. Once you have several features, move routes into static classes with extension methods. The framework does not care where MapGet is called, so the move is mechanical.
Is MapGroup enough, or do I need a library to organize endpoints?
MapGroup plus extension methods covers most needs without a dependency. Third-party libraries add conventions such as endpoint classes discovered by reflection. Those can help large teams, but they are optional and add indirection.
Does AddValidation work with classes in a separate project?
Only if the source generator runs in that assembly. The Microsoft Learn validation page says metadata is generated for the assembly where AddValidation is called, and describes an extension method per assembly that calls it. Plain class libraries also need a package reference to Microsoft.Extensions.Validation.
How do I return consistent error responses?
Return TypedResults.Problem or TypedResults.ValidationProblem from handlers and filters so clients always get the same problem-details shape. Built-in validation already responds with a 400 and error details. Add a test that checks the status code and body for one failing request per feature.