Controllers or Minimal APIs
Decide between controllers and minimal APIs for a .NET 10 service, run both in one app safely, and avoid the split JSON settings trap.
builder.Services.AddControllers();
app.MapControllers();
app.MapGroup("/api/products").MapGet("/{sku}", GetProduct);
builder.Services.ConfigureHttpJsonOptions(o => ...); // minimal
builder.Services.AddControllers().AddJsonOptions(o => ...); // MVCControllers and minimal APIs are two ways to declare endpoints on the same routing system. Both run behind the same middleware, use the same authentication and authorization, write the same problem details and feed the same OpenAPI document. What differs is how handlers are organised, which extension points exist and how much runtime machinery sits between the route and your code.
Side by side
| Concern | Controllers | Minimal APIs |
|---|---|---|
| Organisation | Classes and attributes, conventions via IApplicationModelConvention | Lambdas or static methods, MapGroup per feature |
| Cross-cutting hooks | Authorization, resource, action, exception and result filters | Endpoint filters (one kind) |
| Validation | Automatic with [ApiController] | AddValidation() in .NET 10 |
| Body formats | Input and output formatters, content negotiation, XML possible | JSON only |
JSON Patch, ModelState, [FromForm] models | Mature | Supported, fewer helpers |
| Native AOT and trimming | Not supported | Supported, with the request delegate generator |
| Testing a handler | Construct the controller, call the action | Call the static handler method |
Performance, honestly
Minimal APIs do less work per request: no controller activation, no filter pipeline, no formatter selection. In benchmarks that serve a constant payload the difference is visible; behind a 2 ms database call it is noise. Pick minimal APIs for speed only when you need Native AOT, fast cold starts or a small memory footprint, such as scale-to-zero containers. Do not rewrite working controllers for throughput.
Mixing both in one app
AddControllers() plus MapControllers() and MapGroup(...) coexist without problems, which makes gradual migration possible: new features as groups, old ones left alone. Three things do not cross the boundary:
- Filters. An MVC filter never runs for a minimal endpoint and an endpoint filter never runs for a controller. Put shared concerns in middleware,
IExceptionHandlerand authorization policies. - JSON options.
ConfigureHttpJsonOptionsconfigures minimal APIs;AddControllers().AddJsonOptionsconfigures MVC. Set enum or naming rules in one and the two halves of your API serialise differently. - Route conflicts. The same template and verb in both is not caught at startup; the first matching request fails with an ambiguous match exception and a 500.
What teams decide
dotnet new webapi produces minimal APIs; --use-controllers produces controllers. For a new .NET 10 service, start with minimal APIs organised by feature, typed results and AddValidation(): the old gaps (validation, organisation, OpenAPI) are closed. Keep controllers in an existing codebase that relies on filters, conventions and content negotiation, and when the team already knows MVC well. Mixing within one service is fine during a migration; agree which style new code uses so it does not become two frameworks forever.
Example
When to use it
- Add new reporting endpoints as minimal API groups to an existing controller-based orders service without touching the old controllers.
- Build a scale-to-zero pricing service with Native AOT, which rules out controllers and points to minimal APIs.
- Keep enum serialisation identical across /api/shipments (controller) and /api/v2/shipments (minimal) with one shared JSON setup.
More examples
The same endpoint written both ways
Both versions validate the same record and return 201 or 409. The minimal handler documents both statuses in OpenAPI from its <code>Results<Created<Product>, Conflict></code> return type. <code>ActionResult<Product></code> alone would document only a 200 with <code>Product</code>, so the controller needs the two <code>[ProducesResponseType]</code> attributes to say the same thing. The minimal handler is a static method with explicit dependencies, so a unit test calls it directly without constructing a controller.
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddValidation(); // DataAnnotations for minimal APIs (.NET 10)
builder.Services.AddControllers(); // [ApiController] validates controllers
builder.Services.AddSingleton<ProductCatalog>();
var app = builder.Build();
app.MapControllers();
app.MapGroup("/api/v2/products").MapProductEndpoints();
app.Run();
public record CreateProduct([Required, StringLength(40)] string Sku, [Required] string Name, [Range(0.01, 10000)] decimal Price);
public record Product(string Sku, string Name, decimal Price);
public sealed class ProductCatalog
{
private readonly ConcurrentDictionary<string, Product> _products = new(); // singleton: written by concurrent requests
public bool TryAdd(Product product) => _products.TryAdd(product.Sku, product);
}
// Controller style
[ApiController]
[Route("api/products")]
public sealed class ProductsController(ProductCatalog catalog) : ControllerBase
{
[HttpPost]
[ProducesResponseType<Product>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public ActionResult<Product> Create(CreateProduct request)
{
var product = new Product(request.Sku, request.Name, request.Price);
return catalog.TryAdd(product)
? Created($"/api/products/{product.Sku}", product)
: Conflict();
}
}
// Minimal API style
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProductEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/", Create);
return group;
}
private static Results<Created<Product>, Conflict> Create(CreateProduct request, ProductCatalog catalog)
{
var product = new Product(request.Sku, request.Name, request.Price);
return catalog.TryAdd(product)
? TypedResults.Created($"/api/v2/products/{product.Sku}", product)
: TypedResults.Conflict();
}
}
One JSON policy for both halves
The two option types are different classes: <code>Microsoft.AspNetCore.Http.Json.JsonOptions</code> for minimal APIs and <code>Microsoft.AspNetCore.Mvc.JsonOptions</code> for controllers. One local function applied to both keeps them identical. A contract test that requests the same resource from a controller route and a minimal route and compares the JSON catches drift if someone later edits only one.
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
static void ApplyApiJson(JsonSerializerOptions options)
{
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
}
// Minimal APIs, TypedResults and the problem details writer
builder.Services.ConfigureHttpJsonOptions(o => ApplyApiJson(o.SerializerOptions));
// MVC controllers: input and output formatters
builder.Services.AddControllers().AddJsonOptions(o => ApplyApiJson(o.JsonSerializerOptions));
var app = builder.Build();
app.MapControllers();
app.Run();
Starting points from the templates
The template choice only sets the starting shape; both styles can be added later with <code>AddControllers()</code> or <code>MapGroup</code>. The <code>webapiaot</code> template is the one to start from when Native AOT is a requirement, because controllers cannot be added to an AOT app at all, while a controller app can adopt minimal APIs at any time.
# Minimal APIs (the default for the webapi template)
dotnet new webapi -n Orders.Api
# Controllers instead of minimal APIs
dotnet new webapi -n Billing.Api --use-controllers
# Native AOT-ready minimal API (CreateSlimBuilder, JSON source generation)
dotnet new webapiaot -n Catalog.Api
# See every option the installed template offers
dotnet new webapi --help
Discussion