Articles › ASP.NET Core
Minimal APIs vs Controllers in ASP.NET Core: Which Should You Use?
Minimal APIs vs MVC controllers in ASP.NET Core: syntax, route groups, validation, filters, OpenAPI, testing, performance and which to choose.
Since .NET 6, ASP.NET Core offers two ways to build HTTP APIs: classic controllers (MVC) and Minimal APIs. Both are fully supported, run on the same routing and middleware, and can live in the same project. The question isn't "which one is better" but "which fits this project and team". This article compares them side by side.
The same endpoint, two styles
Controller:
[ApiController]
[Route("api/[controller]")]
public class ProductsController(IProductService products) : ControllerBase
{
[HttpGet("{id:int}")]
[ProducesResponseType<ProductDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> Get(int id, CancellationToken ct)
{
var product = await products.GetAsync(id, ct);
return product is null ? NotFound() : Ok(product);
}
[HttpPost]
public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request, CancellationToken ct)
{
var created = await products.CreateAsync(request, ct);
return CreatedAtAction(nameof(Get), new { id = created.Id }, created);
}
}
builder.Services.AddControllers();
app.MapControllers();
Minimal API:
app.MapGet("/api/products/{id:int}", async Task<Results<Ok<ProductDto>, NotFound>> (
int id, IProductService products, CancellationToken ct) =>
{
var product = await products.GetAsync(id, ct);
return product is null ? TypedResults.NotFound() : TypedResults.Ok(product);
});
app.MapPost("/api/products", async (CreateProductRequest request, IProductService products, CancellationToken ct) =>
{
var created = await products.CreateAsync(request, ct);
return TypedResults.Created($"/api/products/{created.Id}", created);
});
Minimal APIs inject services directly into the handler's parameters and infer binding sources: route values, query string, body (for complex types) and DI services.
Organizing Minimal APIs
The common criticism — "everything ends up in Program.cs" — is solved with route groups and extension methods:
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/products")
.WithTags("Products")
.RequireAuthorization();
group.MapGet("/", GetAll).AllowAnonymous();
group.MapGet("/{id:int}", GetById).WithName("GetProduct");
group.MapPost("/", Create).RequireAuthorization("Admin");
group.MapDelete("/{id:int}", Delete).RequireAuthorization("Admin");
return group;
}
private static async Task<Ok<List<ProductDto>>> GetAll(IProductService products, CancellationToken ct) =>
TypedResults.Ok(await products.GetAllAsync(ct));
private static async Task<Results<Ok<ProductDto>, NotFound>> GetById(int id, IProductService products, CancellationToken ct) =>
await products.GetAsync(id, ct) is { } p ? TypedResults.Ok(p) : TypedResults.NotFound();
private static async Task<Created<ProductDto>> Create(CreateProductRequest request, IProductService products, CancellationToken ct)
{
var created = await products.CreateAsync(request, ct);
return TypedResults.Created($"/api/products/{created.Id}", created);
}
private static async Task<NoContent> Delete(int id, IProductService products, CancellationToken ct)
{
await products.DeleteAsync(id, ct);
return TypedResults.NoContent();
}
}
// Program.cs
app.MapProductEndpoints();
app.MapOrderEndpoints();
Groups share prefixes, tags, authorization, rate limiting, CORS and filters — similar to controller-level attributes. Many teams organize by feature (Features/Products/ProductEndpoints.cs), which pairs well with vertical slice architecture.
Feature comparison
| Feature | Controllers | Minimal APIs |
|---|---|---|
| Routing, auth, CORS, rate limiting | ✅ attributes | ✅ fluent methods / groups |
| Automatic model validation (DataAnnotations) | ✅ [ApiController] returns 400 automatically |
✅ built in from .NET 10 (AddValidation()); earlier: filters or FluentValidation |
| Filters | action/resource/exception filters | endpoint filters (AddEndpointFilter) |
| Model binding customization | rich (model binders, [FromForm], etc.) |
BindAsync/TryParse conventions, [AsParameters] |
| OpenAPI | ✅ | ✅ (TypedResults gives response types automatically) |
| Native AOT | ❌ | ✅ |
| Views / Razor | ✅ | ❌ (API only) |
| Ceremony | more (classes, attributes) | less |
Validation
With [ApiController], invalid DataAnnotations return a ValidationProblemDetails 400 automatically. For Minimal APIs in .NET 10+:
builder.Services.AddValidation();
public record CreateProductRequest(
[Required, MaxLength(120)] string Name,
[Range(0, 1_000_000_000)] decimal Price);
On earlier versions (or for complex rules), use an endpoint filter with FluentValidation:
public class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null) return TypedResults.BadRequest();
var result = await validator.ValidateAsync(argument);
return result.IsValid
? await next(context)
: TypedResults.ValidationProblem(result.ToDictionary());
}
}
group.MapPost("/", Create).AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
TypedResults and OpenAPI
Returning TypedResults with a Results<...> union makes the possible responses part of the method signature. The OpenAPI generator reads them, so documentation stays accurate without ProducesResponseType attributes — and unit tests can assert on the concrete result type:
var result = await ProductEndpoints.GetById(99, fakeService, default);
Assert.IsType<NotFound>(result.Result);
Performance
Minimal APIs have less overhead (no controller activation, no action filter pipeline unless you add filters) and support Native AOT for faster startup and smaller memory. In benchmarks they're measurably faster — but for typical apps where requests hit a database, the difference is small compared to query time. Choose for structure and team preference, not micro-benchmarks — unless you're building high-throughput services or serverless functions where startup time matters.
When to choose controllers
- Large existing codebases already built on MVC.
- Teams used to the MVC conventions and attributes.
- You need features specific to MVC: complex custom model binding,
IActionFilter-based infrastructure, content negotiation with formatters, or Razor views in the same app. - Many endpoints sharing a lot of cross-cutting behavior that the team prefers expressed as attributes.
When to choose Minimal APIs
- New APIs and microservices.
- Vertical slice / feature-folder architecture.
- Small services, webhooks, BFFs (backend-for-frontend), serverless or Native AOT.
- Teams that prefer explicit, less magical code.
Mixing both
Nothing stops you from using both in one app:
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers(); // existing admin API
app.MapGroup("/api/v2").MapProductEndpoints(); // new endpoints
app.MapPost("/webhooks/payment", HandlePaymentWebhook);
This makes gradual migration painless.
Summary
Controllers and Minimal APIs share the same foundation — routing, middleware, DI, authentication and OpenAPI. Controllers offer familiar conventions and the richest MVC features; Minimal APIs offer less ceremony, typed results, endpoint filters, route groups and Native AOT. Organize Minimal APIs with route groups and feature files, validate input with the built-in validation or filters, and feel free to mix both styles while migrating.
FAQ
Are Minimal APIs only for small projects?
No. With route groups and feature-based organization they scale to large APIs. The "minimal" refers to ceremony, not capability.
Will controllers be deprecated?
There's no sign of that. Both are first-class and continue to receive improvements.
Can I use MediatR or Clean Architecture with Minimal APIs?
Yes. Endpoints become thin adapters that send a command or query, exactly like thin controllers.