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.

Syntaxvar api = app.MapGroup("/api").RequireAuthorization(); api.MapOrdersEndpoints(); // Features/Orders/OrdersFeature.cs api.MapCustomersEndpoints(); // Features/Customers/CustomersFeature.cs

Minimal 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

ApproachForAgainst
Explicit MapOrdersEndpoints() callsReadable, no reflection, Native AOT friendly, order is visibleA new endpoint must be added to its feature's map method
IEndpoint classes found by assembly scanningAdding a class is enoughReflection 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 (for CreatedAtRoute and LinkGenerator). The name is also the OpenAPI operationId, which client generators turn into method names, so treat it as public API. Names must be unique across the app: a duplicate makes routing throw InvalidOperationException, 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: WithSummary per 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · text
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 policy

IEndpoint 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.

Example · csharp
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.

Example · csharp
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

  • Be the first to comment on this lesson.