Choosing between Minimal APIs and Controllers shapes your project structure, testability, and performance profile. Both run on the same Kestrel server and middleware pipeline. They differ in how you declare endpoints, bind parameters, validate input, and organize code as the app grows. This guide compares both with working .NET code, benchmarks-driven trade-offs, and refactoring examples.
Quick Takeaways
- Minimal APIs declare endpoints with
MapGet(),MapPost(), and friends. They carry less ceremony and a lighter request path. - Controllers use
[ApiController], attribute routing, and filters. They provide a mature, convention-rich model for large teams. - Both share routing, DI, authentication, and middleware. Neither is “legacy.”
- Use Minimal APIs for microservices and small-to-medium services. Use Controllers when you need MVC-style conventions, heavy model binding customization, or an existing controller codebase.
| Criteria | Minimal APIs | Controllers |
|---|---|---|
| Boilerplate | Low | Medium |
| Startup/Request overhead | Lower | Slightly higher |
| Built-in validation | Via filters or libraries | [ApiController] automatic 400s |
| Filters | Endpoint filters | Action/Resource/Exception filters |
| Organization at scale | Needs route groups/extensions | Natural class-per-resource |
| Learning curve | Gentle | Moderate |
| Native AOT support | Strong | Limited |
What Are Minimal APIs and Controllers?
Minimal APIs
Minimal APIs map HTTP verbs and routes directly to delegates. There is no controller class, no base class, and no attribute routing required.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductService, ProductService>();
var app = builder.Build();
app.MapGet("/products/{id:int}", async (int id, IProductService service) =>
{
var product = await service.GetByIdAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
app.Run();
The route parameter id binds from the path. IProductService resolves from dependency injection automatically. A missing product returns 404; a found one returns 200 with JSON.
Controllers
Controllers group related actions in a class derived from ControllerBase. Attributes define routes, verbs, and behavior.
// Controllers/ProductsController.cs
[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
private readonly IProductService _service;
public ProductsController(IProductService service) => _service = service;
[HttpGet("{id:int}")]
public async Task<ActionResult<Product>> GetById(int id)
{
var product = await _service.GetByIdAsync(id);
return product is null ? NotFound() : Ok(product);
}
}
Setup requires builder.Services.AddControllers() and app.MapControllers(). Behavior matches the Minimal API above: same route, same status codes.
Syntax Breakdown: Side-by-Side
Parameter Binding
Minimal APIs infer binding sources from the parameter type and name. Controllers use the same engine but with [ApiController] inference rules.
// Minimal API: explicit binding attributes
app.MapPost("/orders", (
[FromBody] CreateOrderRequest body,
[FromHeader(Name = "X-Correlation-Id")] string correlationId,
[FromQuery] bool dryRun,
ILogger<Program> logger) =>
{
logger.LogInformation("Order request {CorrelationId}, dryRun={DryRun}", correlationId, dryRun);
return Results.Created($"/orders/{Guid.NewGuid()}", body);
});
Complex types default to body, simple types to route/query, and registered services to DI. Explicit attributes remove ambiguity.
// Controller: same binding
[HttpPost("orders")]
public IActionResult Create(
[FromBody] CreateOrderRequest body,
[FromHeader(Name = "X-Correlation-Id")] string correlationId,
[FromQuery] bool dryRun)
{
_logger.LogInformation("Order request {CorrelationId}, dryRun={DryRun}", correlationId, dryRun);
return Created($"/orders/{Guid.NewGuid()}", body);
}
Return Types
| Approach | Minimal APIs | Controllers |
|---|---|---|
| Typed results | TypedResults.Ok(x), Results<Ok<T>, NotFound> |
ActionResult<T> |
| Implicit serialization | Return an object, get 200 JSON | Return an object from ActionResult<T> |
| OpenAPI metadata | .Produces<T>(), .WithOpenApi() |
[ProducesResponseType] |
TypedResults give compile-time safety and automatic OpenAPI metadata:
app.MapGet("/products/{id:int}", async Task<Results<Ok<Product>, NotFound>> (
int id, IProductService service) =>
{
var product = await service.GetByIdAsync(id);
return product is null ? TypedResults.NotFound() : TypedResults.Ok(product);
});
The union return type tells tooling exactly which responses this endpoint can produce. No extra attributes needed.
Validation: Where the Two Models Differ Most
Controllers with [ApiController] validate DataAnnotations automatically and return a ProblemDetails 400 response. Minimal APIs historically required manual validation. .NET 10 added built-in validation support for Minimal APIs via AddValidation().
public record CreateOrderRequest(
[Required, StringLength(50)] string CustomerName,
[Range(1, 1000)] int Quantity);
// Minimal API (.NET 10+): opt in to built-in validation
builder.Services.AddValidation();
app.MapPost("/orders", (CreateOrderRequest request) =>
TypedResults.Ok(request));
An invalid payload now returns 400 ProblemDetails without handwritten checks. For richer rules, use FluentValidation with an endpoint filter:
// Reusable endpoint filter using FluentValidation
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
var model = context.Arguments.OfType<T>().FirstOrDefault();
if (validator is not null && model is not null)
{
var result = await validator.ValidateAsync(model);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
}
return await next(context);
}
}
app.MapPost("/orders", (CreateOrderRequest r) => TypedResults.Ok(r))
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
The filter runs before the handler, short-circuits with 400 on failure, and is reusable across endpoints.
Filters and Cross-Cutting Concerns
| Concern | Minimal APIs | Controllers |
|---|---|---|
| Authorization | .RequireAuthorization() |
[Authorize] |
| Pre/post logic | Endpoint filters | Action filters |
| Exception handling | UseExceptionHandler() / IExceptionHandler |
Exception filters + middleware |
| Caching | .CacheOutput() |
[ResponseCache], [OutputCache] |
| Rate limiting | .RequireRateLimiting("policy") |
[EnableRateLimiting("policy")] |
Controllers offer more filter stages (Authorization, Resource, Action, Exception, Result). Minimal APIs offer a single, simpler endpoint filter pipeline plus standard middleware. For most services, middleware plus endpoint filters cover everything.
Organizing Minimal APIs at Scale
The common criticism of Minimal APIs: Program.cs becomes a 2,000-line file. Solve it with route groups and extension methods.
// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/products")
.WithTags("Products")
.RequireAuthorization();
group.MapGet("/", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create);
return group;
}
private static async Task<Ok<List<Product>>> GetAll(IProductService service) =>
TypedResults.Ok(await service.GetAllAsync());
private static async Task<Results<Ok<Product>, NotFound>> GetById(
int id, IProductService service)
{
var product = await service.GetByIdAsync(id);
return product is null ? TypedResults.NotFound() : TypedResults.Ok(product);
}
private static async Task<Created<Product>> Create(
Product product, IProductService service)
{
var created = await service.CreateAsync(product);
return TypedResults.Created($"/products/{created.Id}", created);
}
}
// Program.cs stays small
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapCustomerEndpoints();
Static handler methods are easy to unit test and keep Program.cs readable. A group applies shared authorization, tags, and filters once.
Performance Comparison
Minimal APIs skip the MVC action invoker, model-binding infrastructure for controllers, and filter pipeline stages. That produces measurable gains in microbenchmarks, mostly in allocations and per-request overhead.
| Metric | Minimal APIs | Controllers |
|---|---|---|
| Per-request overhead | Lower | Higher (action invocation, filters) |
| Memory allocations | Lower | Higher |
| Startup time | Faster | Slower (controller discovery) |
| Native AOT | Supported | Not supported |
| Real-world impact | Noticeable at very high throughput | Negligible when I/O-bound |
Treat these as directional. Database calls, serialization, and network latency usually dominate. Measure your own workload with BenchmarkDotNet or load tools like k6 before choosing on performance alone.
// Native AOT-friendly Minimal API with source-generated JSON
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
var app = builder.Build();
app.MapGet("/ping", () => new PingResponse("ok"));
app.Run();
public record PingResponse(string Status);
[JsonSerializable(typeof(PingResponse))]
internal partial class AppJsonContext : JsonSerializerContext { }
CreateSlimBuilder plus a JsonSerializerContext removes reflection-based serialization, which Native AOT requires. The result is a small binary with fast cold starts, ideal for containers and serverless.
Testing Both Approaches
Use WebApplicationFactory<T> for integration tests. It works identically for both models.
public class ProductApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductApiTests(WebApplicationFactory<Program> factory) =>
_client = factory.CreateClient();
[Fact]
public async Task GetById_UnknownId_Returns404()
{
var response = await _client.GetAsync("/products/999999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
Add public partial class Program { } to expose the entry point when using top-level statements. The test hits the real pipeline and passes regardless of which model serves the route.
Best Practices: Bad Code vs. Good Code
Anti-Pattern 1: Everything in Program.cs
// BAD: business logic, data access, and routing mixed together
app.MapPost("/orders", async (CreateOrderRequest req, AppDbContext db) =>
{
if (string.IsNullOrWhiteSpace(req.CustomerName)) return Results.BadRequest();
var order = new Order { CustomerName = req.CustomerName, Quantity = req.Quantity };
db.Orders.Add(order);
await db.SaveChangesAsync();
await new SmtpClient().SendMailAsync("a@b.com", req.CustomerName, "Ordered", "...");
return Results.Ok(order);
});
This handler validates, persists, and sends email. It is untestable and tightly coupled.
// GOOD: thin endpoint, logic in a service
app.MapPost("/orders", async Task<Results<Created<OrderDto>, ValidationProblem>> (
CreateOrderRequest req, IOrderService orders, CancellationToken ct) =>
{
var order = await orders.CreateAsync(req, ct);
return TypedResults.Created($"/orders/{order.Id}", order);
})
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
The endpoint only maps HTTP to a service call. Validation lives in a filter, logic in IOrderService, and a CancellationToken flows through.
Anti-Pattern 2: Fat Controllers
// BAD: controller owns data access and business rules
[HttpPost]
public async Task<IActionResult> Create(OrderDto dto)
{
var db = new AppDbContext(); // manual instantiation
var total = dto.Items.Sum(i => i.Price * i.Qty) * 1.2m; // magic number
db.Orders.Add(new Order { Total = total });
db.SaveChanges(); // sync I/O blocks the thread
return Ok();
}
// GOOD: injected service, async, typed result
[HttpPost]
public async Task<ActionResult<OrderDto>> Create(
CreateOrderRequest request, CancellationToken ct)
{
var order = await _orders.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}
The refactor injects dependencies, uses async/await, removes the magic tax rate from the controller, and returns a proper 201 with a Location header.
Decision Guide: Which Should You Choose?
| Scenario | Recommendation |
|---|---|
| New microservice or small API | Minimal APIs |
| Native AOT or serverless cold-start targets | Minimal APIs |
| Large existing MVC/Web API codebase | Controllers (migrate gradually if desired) |
| Team accustomed to MVC conventions | Controllers |
Heavy use of custom model binders, IModelBinder, or complex filter stages |
Controllers |
| Vertical slice or feature-folder architecture | Minimal APIs with route groups |
| Mixed needs | Both in one app |
You can mix both in a single project. Call AddControllers() and MapControllers() alongside MapGet() calls. Migrate endpoint by endpoint rather than rewriting.
Common Edge Cases
Ambiguous binding. A complex parameter without [FromBody] may bind unexpectedly on endpoints with multiple complex types. Annotate explicitly.
Form binding. Minimal APIs support [FromForm] and IFormFile. Antiforgery validation is required for form endpoints, so register AddAntiforgery() and call UseAntiforgery().
Versioning. Use Asp.Versioning.Http for Minimal APIs and Asp.Versioning.Mvc for controllers. Both integrate with route groups or attributes.
OpenAPI. Both models work with the built-in Microsoft.AspNetCore.OpenApi package via AddOpenApi() and MapOpenApi(). Typed results reduce the metadata you write by hand.
FAQ
Are Minimal APIs faster than Controllers in ASP.NET Core?
Yes, usually by a small margin. Minimal APIs skip parts of the MVC pipeline, which cuts allocations and per-request overhead. In I/O-heavy apps, database and network latency dominate, so the difference is rarely visible to users.
Can I use Minimal APIs and Controllers in the same project?
Yes. Register controllers with AddControllers() and MapControllers(), then add endpoints with MapGet() and MapPost(). They share middleware, DI, authentication, and routing, so you can migrate incrementally.
Are Minimal APIs production-ready for large applications?
Yes. Use route groups, static endpoint classes, endpoint filters, and services to keep code organized. Many production systems run Minimal APIs at scale. The main requirement is discipline in structure, since the framework does not impose it.
Do Minimal APIs support validation like Controllers?
Yes. Controllers with [ApiController] validate DataAnnotations automatically. Minimal APIs support built-in validation through AddValidation() in recent .NET versions, or through FluentValidation with a reusable endpoint filter.




