Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 008 / 100

Organize minimal APIs with route groups, explained simply

How I keep 80+ endpoints clean without controllers.

Expona Admin7 min read

Organize Minimal APIs with Route Groups

After shipping 80+ endpoints across multiple platforms without controllers, I learned that minimal APIs don't scale without discipline. The real power isn't in removing boilerplate—it's in organizing minimal APIs with route groups so that adding your 81st endpoint feels as smooth as the first one.

Let me walk you through the pattern that keeps our production systems clean, maintainable, and easy to onboard new developers into.

Feature Extension Methods: One Responsibility, One Place

The foundation of my approach is simple: each feature owns a static class with a single Map method. Program.cs doesn't care about individual endpoints—it just calls the extension method for each feature.

Here's how it looks in practice. Say we have an order management feature with multiple endpoints:

csharp
namespace Orders.Api;

public static class OrderEndpoints
{
    public static void MapOrders(this IEndpointRouteBuilder app)
    {
        var g = app.MapGroup("/api/orders")
            .WithName("Orders")
            .WithOpenApi();

        g.MapGet("/", GetAllOrders)
            .WithName("GetAllOrders")
            .WithOpenApi();

        g.MapGet("/{id:int}", GetById)
            .WithName("GetOrderById")
            .WithOpenApi();

        g.MapPost("/", CreateOrder)
            .WithName("CreateOrder")
            .WithOpenApi();

        g.MapPut("/{id:int}", UpdateOrder)
            .WithName("UpdateOrder")
            .WithOpenApi();

        g.MapDelete("/{id:int}", DeleteOrder)
            .WithName("DeleteOrder")
            .WithOpenApi();
    }

    private static async Task GetAllOrders(IOrderService service) 
        => Results.Ok(await service.GetAllAsync());

    private static async Task GetById(int id, IOrderService service)
    {
        var order = await service.GetByIdAsync(id);
        return order is null ? Results.NotFound() : Results.Ok(order);
    }

    private static async Task CreateOrder(CreateOrderRequest req, IOrderService service)
    {
        var order = await service.CreateAsync(req);
        return Results.Created($"/api/orders/{order.Id}", order);
    }

    private static async Task UpdateOrder(int id, UpdateOrderRequest req, IOrderService service)
    {
        var order = await service.UpdateAsync(id, req);
        return order is null ? Results.NotFound() : Results.Ok(order);
    }

    private static async Task DeleteOrder(int id, IOrderService service)
    {
        var success = await service.DeleteAsync(id);
        return success ? Results.NoContent() : Results.NotFound();
    }
}

In Program.cs, you're left with this clean registration:

csharp
var builder = WebApplicationBuilder.CreateBuilder(args);
builder.Services.AddScoped();

var app = builder.Build();

app.MapOrders();  // That's it. One line per feature.
app.MapInvoices();
app.MapCustomers();
app.MapPayments();

app.Run();

The benefits are immediate. When a teammate adds a new order endpoint, they know exactly where it lives. When you're reviewing code, you navigate to one file. When you refactor authentication logic, you do it in one place for the entire feature. This scales to dozens of features without Program.cs becoming unreadable.

Shared Policies on the Group: Auth, Rate Limits, CORS, and Tags

Where minimal APIs without route groups really break down is applying cross-cutting concerns. Without groups, you'd repeat .RequireAuthorization() and rate limiting on every single endpoint—and you'd forget on the 47th one.

Route groups let you apply policies once to the entire group:

csharp
public static class InvoiceEndpoints
{
    public static void MapInvoices(this IEndpointRouteBuilder app)
    {
        var g = app.MapGroup("/api/invoices")
            .WithName("Invoices")
            .WithOpenApi()
            .RequireAuthorization("AdminOrAccountant")
            .WithOpenApi(operation =>
            {
                operation.Tags = new List { new() { Name = "Invoices" } };
                return operation;
            });

        // These inherit the group's auth, tags, and OpenAPI metadata
        g.MapGet("/", GetAllInvoices)
            .WithName("GetAllInvoices");

        g.MapPost("/", CreateInvoice)
            .WithName("CreateInvoice");

        g.MapGet("/{id:int}/pdf", GeneratePdf)
            .WithName("GenerateInvoicePdf")
            .AllowAnonymous();  // Override group policy when needed

        g.MapDelete("/{id:int}", DeleteInvoice)
            .WithName("DeleteInvoice");
    }

    // Handler methods...
}

Notice the AllowAnonymous() on the PDF generation endpoint—groups are inherited but not immutable. You can override when a specific endpoint has different requirements.

In a real production system, you might have multiple groups within the same feature with different policies:

csharp
public static void MapInvoices(this IEndpointRouteBuilder app)
{
    // Public endpoints: no auth, rate-limited
    var publicGroup = app.MapGroup("/api/invoices")
        .WithName("PublicInvoices")
        .WithOpenApi();

    publicGroup.MapGet("/status/{code}", CheckInvoiceStatus)
        .WithName("CheckStatus");

    // Authenticated admin endpoints: auth required, stricter rate limiting
    var adminGroup = app.MapGroup("/api/invoices/admin")
        .WithName("AdminInvoices")
        .WithOpenApi()
        .RequireAuthorization("Admin");

    adminGroup.MapPost("/batch-process", BatchProcessInvoices)
        .WithName("BatchProcess");

    adminGroup.MapGet("/audit-log", GetAuditLog)
        .WithName("GetAuditLog");
}

This pattern eliminates entire categories of bugs. You'll never again deploy an authenticated endpoint that accidentally required no auth, or forget to tag endpoints for your Swagger UI.

Nested Groups: Clean Versioning and Resource Hierarchies

As your API grows, you need versioning and nested resources. Route groups compose beautifully for this.

Imagine we have versioned orders, and each order has line items:

csharp
public static void MapOrdersV1(this IEndpointRouteBuilder app)
{
    var v1 = app.MapGroup("/api/v1/orders")
        .WithName("OrdersV1")
        .WithOpenApi();

    v1.MapGet("/{orderId:int}", GetOrderV1)
        .WithName("GetOrderV1");

    v1.MapPost("/", CreateOrderV1)
        .WithName("CreateOrderV1");

    // Nested group for line items
    var items = v1.MapGroup("/{orderId:int}/items")
        .WithName("OrderLineItems");

    items.MapGet("/", GetLineItems)
        .WithName("GetLineItems");

    items.MapPost("/", AddLineItem)
        .WithName("AddLineItem");

    items.MapDelete("/{itemId:int}", RemoveLineItem)
        .WithName("RemoveLineItem");
}

public static void MapOrdersV2(this IEndpointRouteBuilder app)
{
    var v2 = app.MapGroup("/api/v2/orders")
        .WithName("OrdersV2")
        .WithOpenApi();

    // V2 might have different handlers, different auth, different schemas
    v2.MapGet("/{orderId:guid}", GetOrderV2)
        .WithName("GetOrderV2");

    var items = v2.MapGroup("/{orderId:guid}/items")
        .WithName("OrderLineItemsV2");

    items.MapGet("/", GetLineItemsV2)
        .WithName("GetLineItemsV2");
}

Then in Program.cs:

csharp
app.MapOrdersV1();
app.MapOrdersV2();

The nesting feels natural because URLs are hierarchical. Your code structure mirrors your API design. Future developers see immediately that line items are a sub-resource of orders. And if v1 and v2 have different middleware or auth policies, each group can declare its own.

Name Every Endpoint: LinkGenerator and Clean OpenAPI IDs

This is a small detail with outsized impact. Every endpoint needs a unique name passed to WithName().

Why? Two reasons:

First, LinkGenerator for internal redirects and responses:

csharp
private static async Task CreateOrder(
    CreateOrderRequest req, 
    IOrderService service,
    LinkGenerator links)
{
    var order = await service.CreateAsync(req);
    var uri = links.GetUriByName("GetOrderById", new { id = order.Id });
    return Results.Created(uri, order);
}

You're not hardcoding the URL path. If you refactor the route later, this code keeps working.

Second, clean OpenAPI operation IDs in Swagger UI:

Without WithName(), the operation ID becomes something like OrderEndpointsGetById (derived from the handler method). With WithName("GetOrderById"), your Swagger UI shows exactly what you named it. When a frontend developer generates a client library from your OpenAPI spec, they get clean method names.

Keep your naming convention consistent. I use Pascal case and lead with the verb: GetOrderById, CreateInvoice, UpdateCustomerAddress. This makes scanning Swagger UI intuitive.

Common Mistakes

Mistake 1: Putting all endpoints in one feature class. I've seen projects with a single ApiEndpoints class containing 200 endpoints. That defeats the purpose. Each feature (Orders, Invoices, Customers) gets its own file. If a feature is still small, it's okay to keep it small—it'll grow into its own space when it needs to.

Mistake 2: Forgetting to name endpoints, then regretting it later. It feels tedious to add .WithName("GetOrderById") to every endpoint. But the day you want to generate a client library or use LinkGenerator, you'll wish you'd named everything. Do it upfront.

Mistake 3: Over-nesting groups. Three levels of nesting is usually the limit before readability suffers. /api/v1/orders/{id}/items/{itemId}/batches is getting deep. Consider whether that structure is truly hierarchical or if you could use query parameters.

Mistake 4: Applying conflicting policies to nested groups. If the parent group requires auth and a child group tries to allow anonymous, you'll see unexpected behavior. Be intentional about policy inheritance. Document which policies can be overridden.

Key Takeaways

  • Each feature gets one static extension class with a single Map method; Program.cs stays clean and readable.
  • Route groups let you apply auth, rate limiting, CORS, and OpenAPI tags once for the entire group—no repetition, no forgotten endpoints.
  • Nest groups to model API hierarchies and versioning; the structure in your code mirrors the structure in your URLs.
  • Always use WithName(); it enables LinkGenerator internally and produces clean OpenAPI operation IDs for clients.
  • One file, one responsibility: keep features separate. When you're at 80 endpoints, you'll have 8–10 well-organized files, not one monster.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles