Organising a Real Minimal API
Structure a growing minimal API by feature, keep Program.cs to a page, and apply consistent endpoint names, tags and summaries.
var api = app.MapGroup("/api").RequireAuthorization();
api.MapOrdersEndpoints(); // Features/Orders/OrdersFeature.cs
api.MapCustomersEndpoints(); // Features/Customers/CustomersFeature.csMinimal APIs do not force a structure, so a service that grows without one ends with a Program.cs of 900 lines of lambdas. The fix is not controllers; it is the same discipline any codebase needs: group code by feature, give each endpoint a home, and make registration explicit.
Organise by feature, not by layer
A folder per feature (Features/Orders, Features/Customers) holds the endpoints, request and response records, and feature services together. A change to "cancel an order" touches one folder instead of Controllers, Services, Models and Validators. Each feature exposes two extension methods: AddOrdersFeature() for its services and MapOrdersEndpoints() for its routes. Program.cs becomes host configuration, one Add and one Map call per feature, and middleware.
One endpoint, one class
Inside a feature, a static class per use case (GetOrder, CreateOrder) contains its Map method, its request and response records and a static Handle method returning typed results. Dependencies arrive as handler parameters, so a unit test calls Handle directly. This is the vertical slice that teams once built with MediatR; MediatR moved to a commercial licence in 2025, and a minimal API handler already is one class per use case, so new code does not need it.
Registration: explicit or scanned
| Approach | For | Against |
|---|---|---|
Explicit MapOrdersEndpoints() calls | Readable, no reflection, Native AOT friendly, order is visible | A new endpoint must be added to its feature's map method |
IEndpoint classes found by assembly scanning | Adding a class is enough | Reflection and trimming warnings, routes appear without anyone reading them, test or sample classes can be picked up |
Choose explicit registration. If a team prefers scanning, pair it with a test that pins the route table so additions are reviewed.
Naming and metadata conventions
- Names:
WithName("GetOrderById")on endpoints that are linked to (forCreatedAtRouteandLinkGenerator). The name is also the OpenAPIoperationId, which client generators turn into method names, so treat it as public API. Names must be unique across the app: a duplicate makes routing throwInvalidOperationException, and every request to the app returns 500, not only the two clashing routes. - Tags: one per feature, set on the feature's group with
WithTags. - Summaries:
WithSummaryper endpoint, written for the API consumer. - Responses: typed results, so response metadata needs no extra calls.
- Policies: authorization and rate limiting on groups, with endpoint overrides only where the exception is the point.
Failure modes
Mapping errors in minimal APIs surface when the endpoint table is first built, at the first request, and take down every route at once: duplicate names, two inferred bodies, an unregistered service inferred as a body. A start-up smoke test that sends one request, plus a route table test, catches all of them in CI.
Example
When to use it
- Split a 900-line Program.cs into Orders, Customers and Invoices feature folders, each with an AddXFeature and MapXEndpoints method.
- Give each endpoint a unique verb-plus-noun name so CreatedAtRoute links work and generated API clients get stable method names.
- Add a route table test so a new or renamed endpoint must be listed in the test before the pull request can merge.
More examples
A feature folder layout
Everything for one use case sits in one file, and everything for one feature in one folder, so a pull request for "cancel an order" is small and reviewable. Shared infrastructure such as the <code>DbContext</code> lives outside the features. The test project mirrors the feature folders, which makes it obvious when a new endpoint arrives without tests.
src/Shop.Api/
Program.cs host setup, AddXFeature(), middleware, MapXEndpoints()
Features/
Orders/
OrdersFeature.cs AddOrdersFeature() + MapOrdersEndpoints() (group, tags)
GetOrder.cs Map + Response + Handle
CreateOrder.cs Map + Request + Response + Handle
CancelOrder.cs
OrderRepository.cs feature-private data access
Customers/
CustomersFeature.cs
GetCustomer.cs
Infrastructure/
Database/ShopDbContext.cs shared by features
ProblemDetailsSetup.cs
tests/Shop.Api.Tests/
Features/Orders/CreateOrderTests.cs unit tests call CreateOrder.Handle directly
RouteTableTests.cs pins every route, name and policyIEndpoint classes registered by scanning
This is the popular alternative: every class implementing <code>IEndpoint</code> is found by reflection, registered in DI and asked to map itself. <code>TryAddEnumerable</code> prevents the same class being registered twice if <code>AddEndpoints</code> is called again. It saves one line per endpoint, at the cost of reflection at start-up, trimming warnings under Native AOT, and routes that exist without appearing in any map method. Use it only together with a route table test.
using System.Reflection;
using Microsoft.Extensions.DependencyInjection.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpoints(typeof(Program).Assembly);
var app = builder.Build();
app.MapEndpoints(app.MapGroup("/api"));
app.Run();
public interface IEndpoint
{
void MapEndpoint(IEndpointRouteBuilder app);
}
public sealed class GetInvoice : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app) =>
app.MapGet("/invoices/{number}", (string number) => TypedResults.Ok(new { number, total = 1250.00m }))
.WithName("GetInvoice")
.WithTags("Invoices");
}
public static class EndpointRegistration
{
// Reflection over the assembly: not trimming or Native AOT friendly.
public static IServiceCollection AddEndpoints(this IServiceCollection services, Assembly assembly)
{
var endpoints = assembly.DefinedTypes
.Where(t => t is { IsAbstract: false, IsInterface: false } && t.IsAssignableTo(typeof(IEndpoint)))
.Select(t => ServiceDescriptor.Transient(typeof(IEndpoint), t));
services.TryAddEnumerable(endpoints);
return services;
}
public static WebApplication MapEndpoints(this WebApplication app, RouteGroupBuilder group)
{
foreach (var endpoint in app.Services.GetRequiredService<IEnumerable<IEndpoint>>())
endpoint.MapEndpoint(group);
return app;
}
}
public partial class Program;A route table test that reviews every change
Resolving <code>EndpointDataSource</code> from the factory builds the real endpoint table, so this test also fails on signature errors that would take the whole app down at the first request, such as two inferred body parameters or an unregistered service inferred as the body. Duplicate endpoint names are different: they throw only when a request is routed, so the test asserts separately that the names are distinct (a start-up smoke request would also catch them). Any added, renamed or removed route changes the list, which forces the change into the reviewed <code>Expected</code> array, a cheap guard against accidental breaking changes to the public contract.
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class RouteTableTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private static readonly string[] Expected =
[
"GET /api/customers/{customerId} GetCustomerById",
"GET /api/orders/{id:int} GetOrderById",
"POST /api/orders/ CreateOrder",
];
[Fact]
public void Routes_and_names_match_the_reviewed_list()
{
var endpoints = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints
.OfType<RouteEndpoint>()
.ToArray();
var actual = endpoints
.Select(e => string.Join(' ',
e.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods.FirstOrDefault() ?? "ANY",
e.RoutePattern.RawText,
e.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? "(unnamed)"))
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(Expected, actual);
// Duplicate names throw only when a request is routed, not when the table is built.
var names = endpoints
.Select(e => e.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName)
.OfType<string>()
.ToArray();
Assert.True(names.GroupBy(n => n).All(g => g.Count() == 1), "Endpoint names must be unique.");
}
}
public partial class Program;
Discussion