Levelwise
English
ASP.NET Core

Minimal API or Controller?

In ASP.NET Core there are two ways to write an endpoint. Both work on the same Pipeline, the same routing and the same DI. The main difference is the shape of the code, the ready-made features, and support for Native AOT.

Not reviewedWritten with AI helpReading time: 12 minShop products API exampleC# and .NET 10 code

Author: bezzad

The problem: two ways to do one job

Our team is writing the shop’s products API. One person says “Let us write Controllers, we have always done it this way”. Another person says “Minimal API is newer and faster”. Both are partly right. To choose well, we must first know where these two are the same and where they differ.

One endpoint, two shapes

We write the same endpoint, “give me one product by its ID”, in both ways.

Controller

[ApiController]
[Route("products")]
public sealed class ProductsController(ShopDb db)
    : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> Get(
        int id, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return NotFound();
        return ProductDto.From(product);
    }
}

// Program.cs
builder.Services.AddControllers();
app.MapControllers();

Minimal API

app.MapGet("/products/{id:int}", async Task<Results<Ok<ProductDto>, NotFound>> (
    int id, ShopDb db, CancellationToken ct) =>
{
    var product = await db.Products.FindAsync([id], ct);
    if (product is null) return TypedResults.NotFound();
    return TypedResults.Ok(ProductDto.From(product));
});

What is the same?

  1. The same Pipeline. Middleware, authentication and error handling work the same for both.
  2. The same routing. The address template and constraints like int are the same.
  3. The same DI. Services come from the same container.
ControllerClass, methods and attributesMinimal APIOne function for each addressShared routingMiddleware: errors, authentication, authorizationDependency injection and host
Both ways are only a shape for writing an endpoint. Under them, the engine is the same.

How does Minimal API work?

Each endpoint is a function. The framework looks at the function parameters and decides by itself where to fill each one from:

int id,int page,CreateProduct command,ShopDb db,CancellationToken ctidFrom the routepageFrom query stringcommandFrom request bodydbFrom DI containerctFrom the request/products/7?page=2{"name": "..."}AddDbContextRequestAbortedIf the framework's guess is not enough, name the source explicitly
For each parameter, the framework guesses the source from its type and name. If needed, make it explicit with an Attribute.
  1. A name that is in the address. Like id, it is read from the route.
  2. Another simple type. Like page, it is read from the Query String.
  3. A complex type. Like the create-product model, it is read from the JSON body.
  4. A registered service. Like ShopDb, it comes from DI.
  5. Special types. Like CancellationToken and HttpContext, they come from the request itself.

An exact return type with TypedResults

In the code above, the return type says exactly that this endpoint returns either 200 with the product, or 404. This type has two benefits:

  • The OpenAPI document is built correctly by itself. You do not need to describe the responses separately.
  • The compiler checks it. If you return another response by mistake, the code does not compile.

Keeping Minimal API tidy

The biggest fear about Minimal API is a Program.cs file with 2000 lines. The solution is simple: put the endpoints of each area in a separate class and group them with the MapGroup method.

Program.cs/productsProducts group/ordersOrders group, all need authGET /{id}POST /GET /{id}POST /Each group in its own file
Each group has an address prefix and shared settings, like authorization. The endpoints inherit these settings.
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProducts(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/products").WithTags("Products");

        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create).RequireAuthorization("CanEditCatalog");

        return group;
    }

    private static async Task<Results<Ok<ProductDto>, NotFound>> GetById(
        int id, ShopDb db, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return TypedResults.NotFound();
        return TypedResults.Ok(ProductDto.From(product));
    }

    private static async Task<Created<ProductDto>> Create(
        CreateProduct command, ShopDb db, CancellationToken ct)
    {
        var product = new Product(command.Name, command.Price);
        db.Products.Add(product);
        await db.SaveChangesAsync(ct);
        return TypedResults.Created($"/products/{product.Id}", ProductDto.From(product));
    }
}

// Program.cs
app.MapProducts();

Now Program.cs has only one line for products. Each area (products, cart, orders) has its own file.

Input validation

In a Controller, the ApiController attribute makes the framework return a 400 response by itself if the input model breaks the rules.

In Minimal API, this was not ready-made before .NET 10. From .NET 10, when you register the validation service, Minimal API has the same behavior:

public sealed record CreateProduct(
    [Required, StringLength(200)] string Name,
    [Range(1.0, 1_000_000_000.0)] decimal Price);

// Program.cs
builder.Services.AddValidation();

If the name is empty, a 400 response with error details (Problem Details) is returned, and the endpoint code does not run.

Shared code before and after the endpoint

Both ways have a way to run shared code:

Filters in Controllers

  • There are many kinds: Action filter, Resource filter, Exception filter and Result filter.
  • They sit on one method, one class or the whole app.
  • Many features, but more to learn.

Filters in Minimal API

  • There is only one kind: Endpoint Filter.
  • It sits on one endpoint or one group.
  • It sees the endpoint arguments and can change the response.

Native AOT

With Native AOT, the app is turned into machine code ahead of time. The app starts much faster and uses less memory. This matters for Serverless and small containers.

  • Minimal API supports Native AOT. The framework creates the parameter binding code at compile time.
  • Controllers (that is, MVC) do not support Native AOT. They depend on a lot of Reflection at run time.

Important rules

  1. Both ways work together in one app. You do not need to rewrite all the old code.
  2. In one project, pick one style as the main one. Mixing without a reason confuses the team.
  3. With Minimal API, group from day one. One file and one MapGroup for each area.
  4. Do not write business logic in the endpoint. In both ways, the endpoint only takes the input, calls a service and returns the response.
  5. Take the cancel token and pass it on. In both ways, one CancellationToken parameter is enough.

Common mistakes

Mistake Result Right way
All endpoints in Program.cs The file becomes big and hard to maintain. One class and one MapGroup for each area.
Thinking Minimal API is only for small projects A good tool is left out. With grouping, it fits big projects too.
Choosing Controllers for a Native AOT app The app does not build with AOT. Minimal API.
Business logic inside a Controller or a Lambda Hard tests and repeated code. Logic in a service or the domain.
Returning a general IResult instead of an exact type The OpenAPI document is incomplete. A TypedResults return with an exact type.

Which one should we choose?

Minimal API

  • A new service, especially a Microservice.
  • A need for Native AOT or a fast start.
  • A team that prefers little, explicit code.

Controller

  • A big existing project written with Controllers.
  • A need for MVC features like a custom Model Binder or the many filter kinds.
  • A team that is comfortable with this structure and has no reason to change.

Summary in five lines

  1. Both ways work on the same Pipeline, routing and DI.
  2. In Minimal API, each endpoint is a function, and the framework fills the parameters by itself.
  3. With MapGroup and one class for each area, Minimal API stays tidy.
  4. From .NET 10, automatic validation is ready for Minimal API too.
  5. Minimal API supports Native AOT. Controllers do not.