Skip to main content
SEMastery
ASP.NETbeginner

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.

13 min readUpdated January 29, 2026

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:

  1. Each feature lives in its own file. Todos in one place, users in another.
  2. A shared prefix is written once. Not /todos repeated on every line.
  3. Shared rules are set once. Things like "you must be logged in" or "check the input first."
  4. Program.cs stays 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

All in Program.cs
Endpoint files
Route groups
Shared rules & DTOs

Steps

1

All in Program.cs

Every route in one file

2

Endpoint files

One file per feature

3

Route groups

Shared prefix with MapGroup

4

Shared rules & DTOs

Auth, filters, clean contracts

The four moves that turn a messy Minimal API into a clean one.

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.

Program.cs calls each feature's own endpoint file.

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.

How MapGroup builds the final route path from prefixes.

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 groupWhat it doesWhen you reach for it
RequireAuthorization()Forces every endpoint to need a logged-in userProtecting a whole feature
AddEndpointFilter(...)Runs shared logic before the handlerValidation, logging, timing
WithTags("Todos")Labels endpoints in Swagger / OpenAPICleaner API documentation
WithOpenApi()Adds rich OpenAPI metadataPublic, documented APIs
MapGroup("/v1")Nests under another prefixVersioning 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

Request
Auth check
Group filter
Handler
Response

Steps

1

Request

GET /todos/5 arrives

2

Auth check

RequireAuthorization on group

3

Group filter

AddEndpointFilter validates

4

Handler

Your code runs

5

Response

TypedResults sent back

Shared group rules run before the matched handler does.

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 IsDeleted flag or a PasswordHash. 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 typeGeneric IResultStrongly typed Ok<T>
OpenAPI accuracyNeeds manual hintsInferred automatically
Unit testingHarder to assertEasy, real type
Recommended in .NET 10Older stylePreferred

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.

An endpoint filter wraps the handler and can stop bad requests early.

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 filters

Each 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 MapGroup and write it once.
  • Returning database entities. Use DTOs so your storage and your API can change independently.
  • Returning Results everywhere. Prefer TypedResults for 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 MapXEndpoints extension method, so Program.cs reads like a table of contents.
  • Use MapGroup to write a shared URL prefix once, and to apply shared rules like RequireAuthorization, WithTags, and AddEndpointFilter to every endpoint in the group at once.
  • Use DTOs such as CreateTodoRequest so your public API contract stays separate from your database entities, and put validation rules on the DTO.
  • Return TypedResults and declare outcomes with unions like Results<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

Related Posts