Minimal APIs in .NET 8: When They Replace Controllers, and When They Don't
Every time we start a new .NET service, someone on the team asks whether this is the project where we finally drop MVC controllers for Minimal APIs. The question usually comes loaded with an expectation: fewer files, less ceremony, a simpler startup. All of that is true on the surface. What isn't true is that Minimal APIs is always the right call just because it's the newer option. It's the right call for one kind of project and the wrong one for another, and mixing up the two is exactly what produces those half-finished migrations that end with a thousand-line Program.cs.
What it actually solves
Minimal APIs closes the gap between "I want an endpoint" and "I have an endpoint." No controller class, no inheriting from ControllerBase, no routing attributes to decorate when the mapping is already spelled out in the call itself:
app.MapGet("/orders/{id}", async (int id, IOrderRepository repo) =>
{
var order = await repo.FindAsync(id);
return order is not null ? Results.Ok(order) : Results.NotFound();
});
Dependency injection arrives as a parameter — no constructor, no private field, no readonly. For a small service with twenty or thirty endpoints, that's not just less code, it's less indirection to read through. Someone new on the team can understand what an endpoint does without jumping to another file to check the controller's constructor.
It also fits services that exist to expose exactly one thing well: a BFF (backend-for-frontend) for a specific screen, a webhook receiver, a small microservice that translates events from one system to another. There, the ceremony MVC brings — routing conventions, global filters, elaborate model binding — is weight the project doesn't need to carry.
Organizing medium-sized projects
The trouble starts once the project outgrows that first file. The temptation is to keep stacking app.MapGet and app.MapPost calls in Program.cs, and three months in, that file has two hundred lines mixing routes with middleware configuration, CORS policy, and service registration. This isn't really a Minimal APIs problem — it's a discipline problem that MVC enforces by convention (every controller gets its own file) and that Minimal APIs doesn't enforce at all.
What works for us is treating each group of endpoints as its own module, using MapGroup for the shared prefix and extension methods to keep registration isolated:
public static class OrderEndpoints
{
public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/{id}", GetOrder);
group.MapPost("/", CreateOrder);
group.MapPut("/{id}/cancel", CancelOrder);
return group;
}
private static async Task<IResult> GetOrder(int id, IOrderRepository repo)
{
var order = await repo.FindAsync(id);
return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound();
}
}
And Program.cs is left with just the registration line:
app.MapGroup("/orders")
.MapOrderEndpoints()
.RequireAuthorization()
.WithTags("Orders");
This recovers most of the organization MVC gives you for free, but it requires the team to maintain it deliberately. If nobody sets that convention from the first sprint, the project drifts toward the monolithic Program.cs without anyone deciding it should, and reversing that later costs more than avoiding it from the start would have.
Filters and validation
This is where the gap with MVC shows up most in day-to-day work. MVC has action filters (IActionFilter, IAsyncActionFilter) with a mature pipeline and declarative attributes like [ValidateAntiForgeryToken] or [Authorize(Roles = "...")] that anyone recognizes instantly. Minimal APIs has IEndpointFilter, which covers the same use case but with fewer conventions already built in:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var arg = context.Arguments.OfType<T>().FirstOrDefault();
var results = new List<ValidationResult>();
if (arg is not null && !Validator.TryValidateObject(arg, new ValidationContext(arg), results, true))
{
return Results.ValidationProblem(results.ToDictionary(
r => r.MemberNames.FirstOrDefault() ?? string.Empty,
r => new[] { r.ErrorMessage ?? string.Empty }));
}
return await next(context);
}
}
It works, and once written it's reused just like an MVC action filter. The real cost isn't writing it once — it's that every team ends up writing it slightly differently, because there's no framework-standard convention for model validation the way [ApiController] provides in MVC — that single attribute turns on automatic model-state validation and returns a 400 without anyone writing an extra line. In Minimal APIs, that behavior has to be built and maintained as project-owned code.
Integration testing
Testing is where the difference matters less than people expect. WebApplicationFactory<TEntryPoint> works identically for both models, because in either case you're exercising the full HTTP pipeline, not the controller class or the endpoint delegate in isolation:
public class OrderEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrderEndpointsTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetOrder_ReturnsNotFound_WhenOrderDoesNotExist()
{
var response = await _client.GetAsync("/orders/999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
The real friction shows up in unit tests that want to isolate an endpoint's logic without spinning up the whole pipeline. With a controller, you instantiate the class directly and call the method. With Minimal APIs, the delegate is usually defined as a static method or an inline lambda inside the registration file, so testing it in isolation means pulling it out into a public, testable method — which is exactly why we broke GetOrder out above. Leave the delegate as an anonymous lambda inside MapGet and it stays welded to the pipeline, testable only end-to-end.
Where it stops scaling
The point where we recommend going back to controllers isn't a magic endpoint count — it's the appearance of needs MVC already solved that Minimal APIs makes you solve again: API versioning with established conventions, OpenAPI documentation grouped by functional area with rich metadata, composed authorization filters with conditional logic, or a large team where consistency across endpoints matters more than how fast one new endpoint gets written.
There's a human factor too: a team that already knows MVC deeply, with years of internal conventions built on top of it, pays a real productivity cost migrating to Minimal APIs even when the project itself is small. That cost never shows up in a framework benchmark — it shows up in how long it takes someone on the team to find where an endpoint's logic actually lives the first time they touch that code.
What we've stopped doing is treating this as a single architectural decision for the whole company. It's a per-service decision. A small, short-lived service, or one with a modest surface area, wins with Minimal APIs. An API with broad surface area, multiple active versions, and a large team that already knows MVC wins by staying on controllers. Mixing the two criteria — choosing by trend instead of by the shape of the project — produces the worst of both: MVC's ceremony without its conventions, or Minimal APIs' simplicity without the discipline it takes to keep it that way.