Skip to main content

How to Move Minimal API Endpoints into Their Own Class

· 4 min read
Jagdish Kumawat
Founder @ Dewiride

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 Features folder in src/Dewiride.Inventory.Api.

Step 1: Create a Class per Feature​

Give each feature its own folder and class. This guide moves categories:

The new files
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:

src/Dewiride.Inventory.Api/Features/Categories/CategoryEndpoints.cs (excerpt)
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;
}
}

The CategoryEndpoints class in a macOS-style code window, with the MapCategoryEndpoints extension method mapping five routes on a group.

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:

src/Dewiride.Inventory.Api/Features/Categories/CategoryEndpoints.cs (excerpt)
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:

src/Dewiride.Inventory.Api/Program.cs (excerpt)
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:

Response (GET /api/v1/categories)
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​

MistakeWhat happensDo this instead
Forgetting the using for the feature's namespaceMapCategoryEndpoints isn't foundAdd the using in Program.cs
Forgetting this on the first parameterIt isn't an extension method, so api.MapCategoryEndpoints() doesn't compileWrite this IEndpointRouteBuilder routes
Taking WebApplication as the parameterIt can't be called on a groupTake IEndpointRouteBuilder
Injecting DbContext into the static classStatic classes have no constructorTake 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​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.