Suthahar Jegatheesan MSDEVBUILD / blog by Suthahar

Structuring a .NET Minimal API Project That Survives Growth

Program.cs stops scaling around twenty endpoints. Here is a vertical-slice layout for Minimal APIs that keeps routing, validation, and handlers organised as the project grows.

By Suthahar Jegatheesan 3 min read views

The Minimal API tutorials all put endpoints in Program.cs. That is fine for a demo and untenable by the twentieth endpoint.

Why does Program.cs stop scaling?

Three reasons compound. Merge conflicts concentrate in one file that every feature branch touches. Related code drifts apart — the endpoint sits in Program.cs while its validation lives three folders away. And discoverability collapses: finding “where orders are handled” becomes a scroll rather than a folder.

The vertical slice layout

Organise by feature, not by technical role:

src/
├─ Program.cs                 // composition only
├─ Features/
│  ├─ Orders/
│  │  ├─ OrderEndpoints.cs    // routes for this feature
│  │  ├─ CreateOrder.cs       // request, response, handler
│  │  ├─ GetOrder.cs
│  │  └─ OrderValidators.cs
│  └─ Customers/
│     ├─ CustomerEndpoints.cs
│     └─ ...
└─ Shared/
   ├─ Endpoints/IEndpointModule.cs
   └─ Persistence/AppDbContext.cs

Everything a change to “create order” touches lives in one folder.

Registering feature modules

Define a tiny contract:

public interface IEndpointModule
{
    void MapEndpoints(IEndpointRouteBuilder app);
}

Then implement it per feature:

public sealed class OrderEndpoints : IEndpointModule
{
    public void MapEndpoints(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/orders")
            .WithTags("Orders")
            .RequireAuthorization();

        group.MapGet("/{id:guid}", GetOrder.Handle).WithName("GetOrder");
        group.MapPost("/", CreateOrder.Handle).WithName("CreateOrder");
    }
}

And discover them all at startup by assembly scan:

public static class EndpointExtensions
{
    public static void MapAllEndpoints(this WebApplication app)
    {
        var modules = typeof(Program).Assembly
            .GetTypes()
            .Where(t => typeof(IEndpointModule).IsAssignableFrom(t)
                        && t is { IsInterface: false, IsAbstract: false })
            .Select(Activator.CreateInstance)
            .Cast<IEndpointModule>();

        foreach (var module in modules)
        {
            module.MapEndpoints(app);
        }
    }
}

Program.cs then stays short no matter how many features exist:

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.MapAllEndpoints();

app.Run();

Handlers as static classes

Each operation gets one file holding its request, response, and handler:

public static class CreateOrder
{
    public record Request(Guid CustomerId, List<LineItem> Items);
    public record Response(Guid OrderId, decimal Total);

    public static async Task<Results<Created<Response>, ValidationProblem>> Handle(
        Request request,
        AppDbContext db,
        IValidator<Request> validator,
        CancellationToken ct)
    {
        var validation = await validator.ValidateAsync(request, ct);
        if (!validation.IsValid)
        {
            return TypedResults.ValidationProblem(validation.ToDictionary());
        }

        var order = Order.Create(request.CustomerId, request.Items);
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);

        return TypedResults.Created(
            $"/orders/{order.Id}",
            new Response(order.Id, order.Total));
    }
}

Static handlers avoid a per-request allocation, and TypedResults gives you accurate OpenAPI output without extra attributes.

Key takeaways

  • Organise by feature; a change should touch one folder.
  • IEndpointModule + assembly scanning keeps Program.cs at composition only.
  • Route groups carry cross-cutting concerns like auth and tags.
  • Static handlers with TypedResults are cheap and self-documenting.

Frequently asked questions

Are Minimal APIs suitable for large applications?
Yes, provided you move endpoint definitions out of Program.cs into feature modules registered via extension methods. The performance and simplicity benefits hold at scale; only the default single-file layout does not.
Share

Get new posts by email

New technical articles, Azure AI and GitHub Copilot updates, and upcoming events. No spam, unsubscribe anytime.

Comments

Comments are not configured yet. Enable Discussions on jssuthahar/blog, install the giscus app, then fill in GISCUS.repoId and GISCUS.categoryId in src/config.ts.