How to Move Minimal API Endpoints into Their Own Class
A minimal API starts with everything in Program.cs, and that stops being pleasant after a few endpoints. The fix uses plain C#: a static class with an extension method that maps the routes, and private methods for the handlers. There's no framework to learn. Program.cs then only says which features exist.
Prerequisites
- An ASP.NET Core minimal API project. The examples use .NET 10, with a
Featuresfolder insrc/Dewiride.Inventory.Api.
Step 1: Create a Class per Feature
Give each feature its own folder and class. This guide moves categories:
src/Dewiride.Inventory.Api/
├── Program.cs
└── Features/
└── Categories/
├── CategoryContracts.cs
└── CategoryEndpoints.cs
Step 2: Add an Extension Method That Maps the Routes
A static class with a method whose first parameter is this IEndpointRouteBuilder adds that method to app, and to groups too, since both are route builders:
namespace Dewiride.Inventory.Api.Features.Categories;
/// <summary>
/// The HTTP endpoints that read and write the categories table.
/// </summary>
public static class CategoryEndpoints
{
public static IEndpointRouteBuilder MapCategoryEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/categories").WithTags("Categories");
group.MapPost("/", CreateAsync).WithSummary("Create a category");
group.MapGet("/", ListAsync).WithSummary("List every category, by name");
group.MapGet("/{id:int}", GetAsync).WithSummary("Get one category");
group.MapPut("/{id:int}", UpdateAsync).WithSummary("Rename a category");
group.MapDelete("/{id:int}", DeleteAsync).WithSummary("Delete a category that has no products");
return routes;
}
}

Returning routes lets callers chain another call if they want to.
Step 3: Move the Handlers into Private Methods
Each lambda becomes a named method. ASP.NET Core reads its parameters the same way it reads a lambda's, so services and the cancellation token still arrive by themselves:
private static async Task<Ok<List<CategoryResponse>>> ListAsync(
InventoryDbContext db,
CancellationToken cancellationToken)
{
var categories = await db.Categories
.OrderBy(c => c.Name)
.Select(c => new CategoryResponse(c.Id, c.Name, c.CreatedAtUtc))
.ToListAsync(cancellationToken);
return TypedResults.Ok(categories);
}
private is fine, because the mapping method is in the same class. A named method also lets you spell out the return type, which TypedResults needs when a handler can return more than one kind of result.
Step 4: Call It from Program.cs
Program.cs is down to a few lines for routes:
using Dewiride.Inventory.Api.Features.Categories;
using Dewiride.Inventory.Api.Features.Products;
// …
// Every data endpoint lives under /api/v1.
var api = app.MapGroup("/api/v1");
api.MapCategoryEndpoints();
api.MapProductEndpoints();
app.Run();
Build and call an endpoint to check it's still there:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
[
{
"id": 1,
"name": "Cables",
"createdAtUtc": "2026-11-04T10:48:37.000261Z"
},
{
"id": 2,
"name": "Chargers",
"createdAtUtc": "2026-11-04T10:48:38.457419Z"
}
]
The feature's class only knows /categories. Program.cs decides that it sits under /api/v1, which is the benefit of mapping on IEndpointRouteBuilder rather than on WebApplication.
Common Mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
Forgetting the using for the feature's namespace | MapCategoryEndpoints isn't found | Add the using in Program.cs |
Forgetting this on the first parameter | It isn't an extension method, so api.MapCategoryEndpoints() doesn't compile | Write this IEndpointRouteBuilder routes |
Taking WebApplication as the parameter | It can't be called on a group | Take IEndpointRouteBuilder |
Injecting DbContext into the static class | Static classes have no constructor | Take it as a handler parameter |
FAQ
Do I need a library such as Carter?
No. This pattern is only C#. A library can find endpoints for you, but this API has a handful of features and Program.cs can name them.
Should the business logic go here too?
For a small API, handlers that use the DbContext directly are easy to follow. When several endpoints share rules, move those into a class of their own and inject it.
Conclusion
Give each feature a static class, map its routes in an extension method on IEndpointRouteBuilder, and move the handlers into private methods. Program.cs then reads as a list of features, and each feature's routes and code sit together.
Additional Resources
- Route handlers in Minimal API apps — lambda and method handlers.
- Extension members — how C# extension methods work.
