DEV Community

Cover image for Controllers vs Minimal APIs: Stop Picking a Winner
Kazem
Kazem

Posted on

Controllers vs Minimal APIs: Stop Picking a Winner

Every .NET 10 project I start now has the same five-minute argument with myself: Controllers or Minimal APIs?

Both are first-class citizens in .NET 10, with the same routing, DI, filters, and model binding under the hood. So the question isn't which one is "better." It's which shape fits the API you're actually building. And that decision matters a lot less than what you do once you've made it.

The actual tradeoff

Here's how I think about it:

Controllers (MVC) Minimal APIs
Best for Large APIs, convention-heavy teams, model binding edge cases, existing MVC codebases, framework integration (ABP, OData) Small/medium services, vertical slices, high-throughput endpoints, AOT scenarios
Discovery Attribute routing, conventions Explicit Map* calls
Overhead Slightly more per request Lowest
Risk "Fat controller" gravity Program.cs becomes a 900-line wall unless you split into endpoint modules

Notice the risk column. Both approaches share the same failure mode: everything ends up in one place because nobody stopped it early. Controllers accrete logic because it's easy to add "just one more method" to an existing class. Minimal APIs accrete Map* calls in Program.cs for the same reason: the file's already open, the pattern's already there, why not.

What matters is keeping either one from collapsing into a dumping ground, not which style you start with.

What that looks like for Minimal APIs

If you're going with Minimal APIs, don't stack your endpoints in Program.cs. Pull them into modules and register them with an extension method:

// Minimal API, organized as a module — not dumped in Program.cs
public static class PostEndpoints
{
    public static IEndpointRouteBuilder MapPosts(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/posts")
            .WithTags("Posts")
            .RequireAuthorization()
            .AddEndpointFilter<ValidationFilter>();

        group.MapGet("/", async (IPostService svc, [AsParameters] PostQuery q, CancellationToken ct)
            => TypedResults.Ok(await svc.SearchAsync(q, ct)));

        group.MapPost("/", async Task<Results<Created<PostDto>, ValidationProblem>>
            (CreatePostRequest req, IPostService svc, CancellationToken ct) =>
        {
            var dto = await svc.CreateAsync(req, ct);
            return TypedResults.Created($"/api/posts/{dto.Id}", dto);
        });

        return app;
    }
}

// Program.cs
app.MapPosts();
Enter fullscreen mode Exit fullscreen mode

MapGroup gives you a single place to attach tags, auth, and filters for a whole slice of routes, instead of repeating .RequireAuthorization() on every individual Map* call. Program.cs stays one line per feature. That's the whole trick, and it only works if you do it from the start, before you have twenty endpoints to retrofit.

What that looks like for Controllers

The controller side has its own version of the same discipline, and it's basically the same spirit I'd apply in Laravel: keep the controller thin and let it do exactly one job, translating an HTTP request into a call on something else.

A few things I hold to on every controller:

Thin. Bind → delegate → shape a response. No EF queries in a controller action, no business rules. If you're writing a Where clause in a controller, that logic belongs in a service.

Always [ApiController]. It gives you automatic 400s on model-binding failures and infers binding sources for you. There's no good reason to opt out of this on a real API controller.

Never return a raw entity. Return IActionResult or TypedResults, and shape the response explicitly. Your database schema is not your API contract. The moment you return an EF entity directly, every column you add to that table becomes a payload change for every client, whether you meant it to or not.

Always accept and forward CancellationToken. And never async void, never .Result, never .Wait(). Under load, blocking on an async call like that is a thread-pool starvation deadlock waiting to happen, the kind of bug that shows up in production instead of dev, under exactly the traffic you were trying to handle.

So which one do I pick

It depends on the shape of what I'm building. That's not a cop-out; it's the actual answer. A handful of high-throughput, independently-scaled endpoints with no shared model-binding complexity? Minimal APIs, one module per feature. A large API integrating with something like ABP or OData, where framework conventions are doing real work for me? Controllers.

What I've stopped doing is treating the choice as the interesting part. What matters is whether, six months in, the routing layer is still organized the way it was on day one: whether Program.cs is still readable, whether controllers are still thin. That's a discipline problem, not an API-style problem.

Pick the shape that fits, then actually enforce the guardrails that keep it from turning into the thing you were trying to avoid in the other approach.

Top comments (0)