Published on

Clean Endpoint Registration in ASP.NET Core Minimal APIs Using Extension Methods

7 min read
Authors
  • avatar
    Name
    Ivan Gechev
    Twitter

In my previous article on dynamic endpoint registration using reflection, we explored an automatic discovery approach that scales well for large applications. However, reflection isn't always needed, sometimes we want something simpler and more explicit.

In this article, we'll build a cat API using a simple extension method approach and see why, in many cases, a more explicit solution is actually the better one.

The Problem We're Solving

Without any organization, our Program.cs class can quickly become a mess:

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();

var app = builder.Build();

app.MapGet("/cats", 
    async (ICatService service) =>
{
    var cats = await service.GetAllAsync();
    return Results.Ok(cats);
})
.WithName("GetAllCats")
.Produces<IEnumerable>(StatusCodes.Status200OK);

app.MapGet("/cats/{id:guid}", 
    async (Guid id, ICatService service) =>
{
    var cat = await service.GetByIdAsync(id);
    return cat is not null ? Results.Ok(cat) : Results.NotFound();
})
.WithName("GetCatById")
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);

app.MapPost("/cats", 
    async (CreateCatRequest request, ICatService service) =>
{
    var cat = await service.CreateAsync(request);
    return Results.Created($"/cats/{cat.Id}", cat);
})
.WithName("CreateCat")
.Produces(StatusCodes.Status201Created)
.ProducesValidationProblem();

// ... more endpoints

// Now imagine veterinarians, appointments...

app.Run();

This file pushes 50+ lines with just one resource type. As we add more and more endpoints, it will become increasingly difficult to navigate.

Creating Static Extension Classes

The solution we'll explore is moving related endpoints into their own static classes:

CatEndpoints.cs
public static class CatEndpoints
{
    public static void MapCatEndpoints(this IEndpointRouteBuilder routes)
    {
        routes.MapGet("/cats", 
            async (ICatService service) =>
        {
            var cats = await service.GetAllAsync();
            return Results.Ok(cats);
        })
        .WithName("GetAllCats")
        .Produces<IEnumerable<Cat>>(StatusCodes.Status200OK);

        routes.MapGet("/cats/{id:guid}", 
            async (Guid id, ICatService service) =>
        {
            var cat = await service.GetByIdAsync(id);
            return cat is not null ? Results.Ok(cat) : Results.NotFound();
        })
        .WithName("GetCatById")
        .Produces<Cat>(StatusCodes.Status200OK)
        .Produces(StatusCodes.Status404NotFound);

        routes.MapPost("/cats", 
            async (CreateCatRequest request, ICatService service) =>
        {
            var cat = await service.CreateAsync(request);
            return Results.Created($"/cats/{cat.Id}", cat);
        })
        .WithName("CreateCat")
        .Produces<Cat>(StatusCodes.Status201Created)
        .ProducesValidationProblem();
    }
}

We create the CatEndpoints class and mark it as static. Inside, we define the MapCatEndpoints() method which extends IEndpointRouteBuilder. The this keyword is what makes this an extension method.

Want to learn about properly documenting Minimal API responses? Check out my article on Defining Response Types in ASP.NET Core Minimal APIs.

Why IEndpointRouteBuilder?

We extend IEndpointRouteBuilder rather than WebApplication directly because it's the interface that provides routing functionality. WebApplication implements this interface, which is why we can call routing methods on it.

This design choice pays off when you want to use route groups:

Program.cs
var api = app.MapGroup("/api");

// This works because MapGroup() returns an IEndpointRouteBuilder instance
api.MapCatEndpoints();

The interface approach makes our extension methods more flexible.

Our Clean Program.cs

With our endpoints organized into extension methods, Program.cs becomes beautifully clean:

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();

var app = builder.Build();

app.MapCatEndpoints();
app.MapBreedEndpoints();

app.Run();

That's it! When we call app.MapCatEndpoints(), we invoke the extension method we defined in the CatEndpoints class. At runtime, the WebApplication instance gets passed to our method as the routes parameter. Inside that method, all our routes.MapGet(), routes.MapPost(), and other mapping calls execute, registering each endpoint with ASP.NET Core's routing system.

You want to see all cat endpoints? Open the CatEndpoints.cs class. Want to temporarily disable the cat endpoints? Comment out one line.

This explicitness is the key advantage. There's no magic or conventions to remember, no wondering "where did this endpoint come from?" You can see the entire endpoint registration flow at a glance. Compare this to having 50+ lines of cat endpoint definitions directly in the Program.cs class - we get the same organization and separation of concerns, but with complete visibility into what's being registered.

Why This Approach Scales Well

This pattern works because it strikes a balance between structure and explicitness.

Each endpoint group:

  • Has a clear responsibility
  • Lives in a single, discoverable file
  • Is registered explicitly in the Program.cs class

There’s no hidden behavior and no reflection-based magic. When something breaks, you know exactly where to look.

Extension Methods vs Reflection: Which to Choose?

Here's my take based on real-world experience:

Use extension methods when:

  • Your API has 5-30 resources
  • You prefer explicit code over conventions
  • You're the a solo developer or work in a small team

Use reflection when:

  • Your API has dozens of resources
  • You want convention-based architecture
  • You have a large team that needs consistency

For most projects, start with extension methods. They're simpler and more explicit. If your API grows beyond the originally defined proportions and you find yourself wishing for automatic discovery, you can migrate to the reflection approach from my previous article.

Conclusion

Extension methods provide us with a clean and explicit way to organize our Minimal API endpoints without the added reflection complexity. By creating static classes with well-named extension methods, we keep the Program.cs class concise while maintaining complete control over registration. This approach works beautifully for small to medium-sized APIs where explicitness and simplicity are more valuable than automatic discovery.