How to Structure Minimal APIs in ASP.NET Core (.NET 10)
Learn how to structure Minimal APIs in ASP.NET Core with route groups, endpoint files, DTOs, TypedResults, and filters. Beginner-friendly with diagrams.
A food stall menu, not a giant restaurant binder
Picture a small chaat stall near your school. The owner does not hand you a thick binder with every dish in the city. He has one small board: gol gappe, bhel, dahi puri, and the prices next to each. You read it in five seconds and you order.
Now imagine the same owner grows into a big food court with ten counters. If he still kept one giant board with hundreds of items mixed together, nobody could find anything. So instead he gives each counter its own small board. The dosa counter lists only dosas. The juice counter lists only juices. There is a clear sign above each counter so you know where to walk.
A Minimal API in ASP.NET Core starts like that first tiny board. You write a few MapGet and MapPost lines in Program.cs and it just works. But as your app grows, that one file becomes the giant mixed-up board that nobody wants to read.
Structuring your Minimal API means giving each feature its own counter, with its own small board and a clear sign on top. In this post we will learn the simple, friendly way to do that, using the tools .NET 10 gives us: route groups, endpoint files, DTOs, TypedResults, and filters.
What "structure" actually means here
Before we touch code, let us agree on what a good structure gives us. We want four things:
- Each feature lives in its own file. Todos in one place, users in another.
- A shared prefix is written once. Not
/todosrepeated on every line. - Shared rules are set once. Things like "you must be logged in" or "check the input first."
Program.csstays short. It should read like a table of contents, not a novel.
Here is the journey we are going to take, from messy to tidy.
From one big file to a tidy structure
Steps
All in Program.cs
Every route in one file
Endpoint files
One file per feature
Route groups
Shared prefix with MapGroup
Shared rules & DTOs
Auth, filters, clean contracts
Step 0: The messy starting point
Almost every tutorial begins here. You create a new project and pile everything into Program.cs. It looks fine at first.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var todos = new List<Todo>();
app.MapGet("/todos", () => todos);
app.MapGet("/todos/{id:int}", (int id) =>
todos.FirstOrDefault(t => t.Id == id) is { } todo
? Results.Ok(todo)
: Results.NotFound());
app.MapPost("/todos", (Todo todo) => { todos.Add(todo); return Results.Created($"/todos/{todo.Id}", todo); });
app.MapDelete("/todos/{id:int}", (int id) => { todos.RemoveAll(t => t.Id == id); return Results.NoContent(); });
app.Run();
record Todo(int Id, string Title, bool IsDone);This works. But notice how /todos is repeated on every line. Now imagine adding users, products, and orders. Each one adds four or five more lines. Soon Program.cs is hundreds of lines long, and one small typo can break the whole startup. We can do much better.
Step 1: Move each feature into its own endpoint file
The first and biggest improvement is also the easiest. We move our todo routes out of Program.cs and into a small static class. A static class with an extension method lets us call one tidy method from Program.cs.
public static class TodosEndpoints
{
public static IEndpointRouteBuilder MapTodosEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/todos", () => Results.Ok(TodoStore.All()));
app.MapGet("/todos/{id:int}", (int id) =>
TodoStore.Find(id) is { } todo
? Results.Ok(todo)
: Results.NotFound());
app.MapPost("/todos", (Todo todo) =>
{
TodoStore.Add(todo);
return Results.Created($"/todos/{todo.Id}", todo);
});
return app;
}
}Now Program.cs becomes a clean table of contents:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapTodosEndpoints();
app.MapUsersEndpoints();
app.MapProductsEndpoints();
app.Run();Read those three lines aloud. They tell you, in plain words, what your API offers. That is the food court sign above each counter. To add a new feature, you make one new file and add one line here.
Step 2: Group related endpoints with MapGroup
Look again at our endpoint file. The prefix /todos is still written on every single route. If we ever rename it to /api/todos, we have to change it in many places, and we might miss one.
This is exactly what MapGroup is for. It lets us write the shared prefix once. Every route mapped onto the group automatically gets that prefix in front.
public static class TodosEndpoints
{
public static IEndpointRouteBuilder MapTodosEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/todos");
group.MapGet("/", () => Results.Ok(TodoStore.All()));
group.MapGet("/{id:int}", (int id) =>
TodoStore.Find(id) is { } todo
? Results.Ok(todo)
: Results.NotFound());
group.MapPost("/", (Todo todo) =>
{
TodoStore.Add(todo);
return Results.Created($"/todos/{todo.Id}", todo);
});
return app;
}
}See the difference? The routes now say /, /{id:int}, and /. The /todos part lives in one place. Rename it once and every endpoint follows. Note that the route GET /{id:int} inside the group really means GET /todos/{id:int} once the prefix is added.
Groups can even be nested. You can make a /api group, then a /todos group inside it, so the final path becomes /api/todos. This is handy for things like API versioning, where you might have an outer /v1 group.
Step 3: Set shared rules once for the whole group
Here is where groups truly shine. A group is not just a prefix. It is a place to apply settings that every endpoint inside should share. You call the method once on the group, and all the endpoints inherit it.
The most common shared settings are in the table below.
| Method on the group | What it does | When you reach for it |
|---|---|---|
RequireAuthorization() | Forces every endpoint to need a logged-in user | Protecting a whole feature |
AddEndpointFilter(...) | Runs shared logic before the handler | Validation, logging, timing |
WithTags("Todos") | Labels endpoints in Swagger / OpenAPI | Cleaner API documentation |
WithOpenApi() | Adds rich OpenAPI metadata | Public, documented APIs |
MapGroup("/v1") | Nests under another prefix | Versioning and sub-resources |
Let us apply a couple of these. We want every todo endpoint to require a logged-in user and to appear under the "Todos" tag in Swagger.
var group = app
.MapGroup("/todos")
.RequireAuthorization()
.WithTags("Todos");That is it. We did not touch any individual route, yet every one of them is now protected and tagged. If a new teammate adds a fifth todo endpoint to this group next month, it is protected and tagged automatically. They cannot forget, because the rule lives on the group, not on each line.
A request flowing through a configured group
Steps
Request
GET /todos/5 arrives
Auth check
RequireAuthorization on group
Group filter
AddEndpointFilter validates
Handler
Your code runs
Response
TypedResults sent back
Step 4: Use DTOs, not your database entities
A DTO (Data Transfer Object) is just a small, plain class that describes exactly the data your API sends or receives. It is the menu card. It is not the kitchen.
It is tempting to accept and return your database entity directly. Please resist that. Two reasons:
- Safety. Your entity may have fields you never want the outside world to see or set, like an internal
IsDeletedflag or aPasswordHash. A DTO only exposes what you choose. - Stability. If your database shape changes, your public API should not break. A DTO is a wall between your storage and your contract.
So we create a request DTO for input and keep a separate response shape. The request DTO is also the perfect home for validation rules.
// What the client is allowed to send when creating a todo.
public record CreateTodoRequest(string Title);
// What we store internally. The client never sets Id or CreatedAt.
public record Todo(int Id, string Title, bool IsDone, DateTime CreatedAt);
group.MapPost("/", (CreateTodoRequest request) =>
{
var todo = new Todo(
Id: TodoStore.NextId(),
Title: request.Title,
IsDone: false,
CreatedAt: DateTime.UtcNow);
TodoStore.Add(todo);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});Notice the client cannot set Id, IsDone, or CreatedAt. We decide those on the server. That small wall prevents a whole class of bugs and security holes.
Step 5: Return TypedResults for clear, testable endpoints
In our early code we returned Results.Ok(...) and Results.NotFound(). These work, but they all hide behind a generic IResult type. The framework cannot tell, just by looking, which status codes an endpoint might return. That makes your OpenAPI docs vaguer and your tests harder.
TypedResults fixes this. It returns a strongly typed result. You can even declare all the possible outcomes with a union type, which reads like a tiny contract.
group.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id) =>
{
var todo = TodoStore.Find(id);
return todo is not null
? TypedResults.Ok(todo)
: TypedResults.NotFound();
});Read the signature: Results<Ok<Todo>, NotFound>. At a glance, anyone can see this endpoint returns either a 200 OK with a Todo, or a 404 Not Found. Swagger picks this up automatically and shows both responses. And in a unit test, you can assert on the real Ok<Todo> type instead of poking at a generic interface.
Here is a quick comparison so the choice is clear.
Results.Ok(...) | TypedResults.Ok(...) | |
|---|---|---|
| Return type | Generic IResult | Strongly typed Ok<T> |
| OpenAPI accuracy | Needs manual hints | Inferred automatically |
| Unit testing | Harder to assert | Easy, real type |
| Recommended in .NET 10 | Older style | Preferred |
Step 6: Endpoint filters for shared cross-cutting work
Sometimes you want a small piece of logic to run before many handlers: checking that input is valid, logging how long a call took, or blocking empty requests. Copying that logic into every handler is wasteful and easy to get wrong.
An endpoint filter is a neat hook that runs around your handler, a bit like a security guard at the counter who checks your token before you order. You attach it once to a group and it guards every endpoint inside.
public class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var arg = context.Arguments.OfType<T>().FirstOrDefault();
if (arg is CreateTodoRequest req && string.IsNullOrWhiteSpace(req.Title))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["Title"] = ["Title is required."]
});
}
return await next(context);
}
}
// Attach it to just the create endpoint, or to the whole group.
group.MapPost("/", CreateTodo)
.AddEndpointFilter<ValidationFilter<CreateTodoRequest>>();If the title is blank, the filter stops the request early and returns a clean 400 with a problem description. The handler never even runs with bad data. This keeps your handlers focused on the happy path, which makes them much easier to read.
A recommended folder layout
Once you have a few features, a simple folder layout keeps everything findable. You do not need a fancy architecture to start. This is plenty for most apps.
// Project layout (not code, shown for shape)
// src/
// Program.cs -> just the table of contents
// Features/
// Todos/
// TodosEndpoints.cs -> MapTodosEndpoints + MapGroup("/todos")
// TodoDtos.cs -> CreateTodoRequest, TodoResponse
// TodoStore.cs -> data access for todos
// Users/
// UsersEndpoints.cs
// UserDtos.cs
// Common/
// ValidationFilter.cs -> shared endpoint filtersEach feature folder is a counter in the food court: it holds its own endpoints, its own DTOs, and its own data access. When you work on todos, everything you need is in one folder. When a new developer joins, they can guess where things live.
A note on libraries: keep it simple first
You may have heard of libraries that add messaging or mediator patterns on top of your API. Two popular ones, MediatR and MassTransit, moved to a commercial license in recent versions. That means for larger teams or companies, they may now require a paid license. For learning and for many apps, you do not need them at all. Plain endpoint methods, groups, DTOs, and filters, all built into ASP.NET Core, will take you a very long way. Reach for extra libraries only when you have a real problem they solve, not just because a tutorial used them.
Putting it all together
Here is the shape of a fully structured feature, combining everything above.
public static class TodosEndpoints
{
public static IEndpointRouteBuilder MapTodosEndpoints(this IEndpointRouteBuilder app)
{
var group = app
.MapGroup("/todos")
.RequireAuthorization()
.WithTags("Todos");
group.MapGet("/", () => TypedResults.Ok(TodoStore.All()));
group.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id) =>
TodoStore.Find(id) is { } todo
? TypedResults.Ok(todo)
: TypedResults.NotFound());
group.MapPost("/", (CreateTodoRequest request) =>
{
var todo = TodoStore.Create(request.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
})
.AddEndpointFilter<ValidationFilter<CreateTodoRequest>>();
return app;
}
}And Program.cs stays calm and readable:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapTodosEndpoints();
app.MapUsersEndpoints();
app.Run();That is a real, production-shaped Minimal API. Each feature is isolated. Shared rules are set once. The public contract is protected by DTOs. The responses are typed and documented. And the entry file reads like a menu.
Common mistakes to avoid
- Leaving everything in
Program.cs. It feels fast on day one and painful on day thirty. Move features out early. - Repeating the prefix on every route. Use
MapGroupand write it once. - Returning database entities. Use DTOs so your storage and your API can change independently.
- Returning
Resultseverywhere. PreferTypedResultsfor better docs and tests. - Copy-pasting validation into every handler. Use an endpoint filter on the group instead.
- Adding heavy libraries too soon. Start with built-in features; add tools only when a real need appears.
Quick recap
- A Minimal API starts simple but gets messy if everything lives in
Program.cs. - Move each feature into its own static class with a
MapXEndpointsextension method, soProgram.csreads like a table of contents. - Use
MapGroupto write a shared URL prefix once, and to apply shared rules likeRequireAuthorization,WithTags, andAddEndpointFilterto every endpoint in the group at once. - Use DTOs such as
CreateTodoRequestso your public API contract stays separate from your database entities, and put validation rules on the DTO. - Return
TypedResultsand declare outcomes with unions likeResults<Ok<Todo>, NotFound>for accurate OpenAPI docs and easy unit tests. - Use endpoint filters for cross-cutting work like validation, so handlers stay focused on the happy path.
- Keep a simple feature-folder layout, and add extra libraries (note MediatR and MassTransit are now commercially licensed) only when you have a real need.
References and further reading
- Minimal APIs quick reference — Microsoft Learn
- Route handlers in Minimal API apps — Microsoft Learn
- Tutorial: Create a Minimal API with ASP.NET Core — Microsoft Learn
- Minimal API Endpoints in ASP.NET Core — codewithmukesh
- Organizing ASP.NET Core Minimal APIs — Tess Ferrandez
Related Posts
Automatically Register Minimal APIs in ASP.NET Core
Learn to auto-register Minimal API endpoints in ASP.NET Core using the IEndpoint pattern, assembly scanning, and source generators. With diagrams and code.
API Versioning in ASP.NET Core: A Friendly, Complete Guide
Learn API versioning in ASP.NET Core with simple examples. URL, query string, header, and media type versioning explained with diagrams, code, and OpenAPI tips.
Authentication and Authorization Best Practices in ASP.NET Core
A friendly guide to authentication and authorization in ASP.NET Core for .NET 10 — JWT, cookies, claims, roles, policies, and security best practices with diagrams.
Building Fast Serverless APIs With Minimal APIs on AWS Lambda
Learn to run ASP.NET Core Minimal APIs on AWS Lambda for fast, cheap serverless APIs. Covers setup, cold starts, Native AOT, and .NET 10 with diagrams and code.
Best Practices for Building REST APIs in ASP.NET Core
A friendly, beginner guide to REST API best practices in ASP.NET Core with naming, status codes, validation, ProblemDetails, paging, versioning, security, and code.
How to Be More Productive When Creating CRUD APIs in .NET
Learn simple, modern ways to build CRUD APIs faster in .NET 10. Scaffolding, minimal APIs, EF Core, DTO mapping, and reusable patterns explained for beginners.