Minimal APIs vs Controllers: Where Cross-Cutting Code Lives in ASP.NET Core
Minimal APIs cut controller ceremony, but the real decision is where validation, filters, and error shaping live. A products CRUD example, plus how to check the status codes.
21 Dec 2025, 17:44 UTC

Six endpoints, four files, and one real question
A small internal service needs to list products, fetch one by id, create, and delete. In the controller style that is a ProductsController class, [ApiController], [Route("api/[controller]")], four action methods, and the registration plumbing in Program.cs. For a service that will never grow past a handful of routes, most of that is ceremony.
Minimal APIs, introduced in ASP.NET Core 6, map an HTTP verb directly to a delegate. The usual pitch is "less code." The more useful framing is this: the decision is about where cross-cutting behavior lives. Minimal APIs do not remove the need for validation, authorization, or consistent error shapes — they stop handing you a framework-shaped place to put them. Whether that is a win depends on how much of that behavior you actually have.
What Minimal APIs reuse rather than replace
"Minimal" sounds like a separate stack. It is not. A minimal API app runs on the same WebApplication host, the same middleware pipeline, the same IConfiguration, the same logging abstractions, and the same dependency injection container. app.UseAuthentication(), app.UseAuthorization(), and custom middleware behave exactly as they do with controllers.
Handlers can take services as parameters, and scoped services resolve once per request:
app.MapGet("/products/{id:int}", (int id, AppDb db) => ...);AppDb comes from the request scope; nothing is constructed by hand. Route constraints such as :int apply, because this is the same routing engine controllers use.
A worked example: products CRUD
Assumptions: .NET 7 or later (route groups and TypedResults arrived in 7), EF Core with the SQLite provider, and a ProductService that wraps the DbContext. The sketch below is illustrative and has not been executed here.
// Program.cs
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDb>(o => o.UseSqlite("Data Source=app.db"));
builder.Services.AddScoped<ProductService>();
var app = builder.Build();
var products = app.MapGroup("/products");
products.MapGet("/", async (ProductService svc) =>
Results.Ok(await svc.ListAsync()));
products.MapGet("/{id:int}", async (int id, ProductService svc) =>
await svc.FindAsync(id) is Product p
? Results.Ok(p)
: Results.NotFound());
products.MapPost("/", async (Product input, ProductService svc) =>
{
var created = await svc.AddAsync(input);
return Results.Created($"/products/{created.Id}", created);
});
products.MapDelete("/{id:int}", async (int id, ProductService svc) =>
await svc.DeleteAsync(id) ? Results.NoContent() : Results.NotFound());
app.Run();MapGroup prefixes every route in the group, so /products/1 matches without repeating the segment. Results.Created returns 201 with a Location header; Results.NoContent returns 204.
One gotcha worth knowing before you refactor: Results returns IResult, which keeps branching handlers simple. TypedResults returns concrete types and gives you a compile-time result union, but when a handler branches between different result shapes you must declare the return type explicitly — for example Task<Results<Ok<Product>, NotFound>> — so the compiler can pick the union. Without that, the conditional expression has no common type to infer.
Where controllers still earn their keep
Minimal APIs have narrowed the gap. Endpoint filters (AddEndpointFilter, .NET 7+) cover per-endpoint concerns, and [AsParameters] groups related query parameters into a single binding target. But controllers still provide conventions that large codebases lean on: action filters, custom model binders, [FromBody]/[FromQuery] behavior that is documented and widely understood, and a predictable place for a new developer to look.
The practical test is not line count. It is: how many cross-cutting behaviors do you have, and do you want them discovered by convention or declared at each endpoint? Two or three concerns across a dozen routes favors minimal APIs. Fifteen concerns across two hundred routes favors controllers — or minimal APIs organized into per-feature endpoint classes with route groups.
Adopting it without painting yourself in
- Start with
dotnet new web -n MinimalDemoand add one endpoint:app.MapGet("/hello", () => "Hello"). Rundotnet runand use the localhost URL it prints. - Put business logic in a service layer, not in the handler. Handlers should translate HTTP into a call and a result back into HTTP.
- Keep validation and authorization explicit. Recent releases have added validation helpers for minimal APIs; confirm what your target framework actually ships rather than copying a snippet from an older post.
- Migrate controller actions only when the endpoint shape stays simple. An action with three filters and a custom binder is not a migration candidate.
Checking the result
With the app running, exercise the routes and check status codes rather than eyeballing the response body:
curl -i http://localhost:5000/products/1
curl -i -X POST http://localhost:5000/products \
-H "Content-Type: application/json" \
-d '{"name":"Widget","price":9.99}'
curl -i -X DELETE http://localhost:5000/products/1Expect 200 for reads, 201 with a Location header for creates, 204 for deletes, and 404 for an id that does not exist. Substitute the port dotnet run printed.
For API exploration, .NET 6–8 projects commonly use Swashbuckle: builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); plus app.UseSwagger(); app.UseSwaggerUI();. Newer releases ship a built-in OpenAPI document generator and changed the template defaults, so verify the current approach for your target framework before assuming either snippet applies.
Two limitations to keep in mind. First, behavior is version-sensitive — binding, filters, route handler builders, and OpenAPI support all changed across .NET 6, 7, and 8 — so pin your target framework in the project file and read the docs for that version. Second, the example writes an app.db SQLite file in the working directory; deleting it resets the data, which is the only state this walkthrough changes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.