Why I Ditched Controllers for Minimal APIs (And You Should Too)
When Microsoft initially introduced Minimal APIs in .NET 6, my immediate reaction was highly skeptical. I thought, "Great, they are just copying Express.js to appeal to Node developers who don't like object-oriented programming."
I was perfectly happy with my MVC Controllers. I liked my [ApiController] attributes, my routing attributes on every method, and my heavily structured Controllers/ folder. I dismissed Minimal APIs as a toy framework meant for building tiny microservices or teaching beginners.
Fast forward to today, and I haven’t written a traditional Controller in over a year. I use Minimal APIs for absolutely everything—from small worker services to massive enterprise B2B gateways.
Here is an exhaustive deep-dive into why I completely changed my mind, how Minimal APIs actually work under the hood, and why you should seriously consider ditching legacy Controllers for your next .NET project.
Table of Contents
- 1. The Controller Legacy
- 2. Eliminating the Boilerplate
- 3. The Performance Argument (Why Controllers Are Slow)
- 4. Native AOT Compatibility
- 5. "But my Program.cs will be massive!" (How to Architect)
- 6. Third-Party Solutions: Carter
- 7. Filters and Middleware in Minimal APIs
- Final Verdict
1. The Controller Legacy
ASP.NET Core MVC Controllers have been the gold standard in the .NET ecosystem for nearly a decade. They are deeply integrated into the framework, heavily documented, and universally understood by every C# developer on the planet.
However, Controllers carry a massive amount of historical baggage. They were originally designed in an era where web applications returned full HTML views (using Razor). As the industry shifted toward Single Page Applications (React, Angular) and JSON REST APIs, Microsoft tried to strip down MVC by introducing [ApiController] and ControllerBase (which removed the view-rendering engines).
But fundamentally, the architecture remained the same: heavily reflection-based, highly allocated, object-oriented routing. Minimal APIs were built from the ground up to be a true, modern, cloud-native replacement for REST endpoints.
2. Eliminating the Boilerplate
To understand the appeal of Minimal APIs, you first have to look at the boilerplate required for a standard ASP.NET Core Controller.
// The old, bloated way
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IProductService productService, ILogger<ProductsController> logger)
{
_productService = productService;
_logger = logger;
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProduct(int id)
{
_logger.LogInformation("Fetching product...");
var product = await _productService.GetByIdAsync(id);
if (product == null) return NotFound();
return Ok(product);
}
}
That is roughly 25 lines of code for a single GET endpoint. You have class declarations, constructor injection, routing attributes, and IActionResult wrappers.
Here is the exact same logic written as a Minimal API in Program.cs:
// The new, clean way
app.MapGet("/api/products/{id}", async (int id, IProductService svc, ILogger<Program> logger) =>
{
logger.LogInformation("Fetching product...");
var product = await svc.GetByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
})
.Produces<ProductDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
The boilerplate is entirely gone. The dependencies (IProductService, ILogger) are injected directly into the delegate signature, completely skipping constructor initialization. It is instantly readable, concise, and incredibly expressive. (Bonus points if your DTOs are C# Records).
3. The Performance Argument (Why Controllers Are Slow)
Concise syntax is nice, but it isn't enough to justify an architectural rewrite. Performance is the real reason I switched.
Controllers are slow. Not "slow" in a way a human notices when clicking a button, but slow in a high-throughput, microservice environment.
When an HTTP request hits a Controller, the ASP.NET Core routing engine has to:
- Use Reflection to scan the assembly and find the matching controller class.
- Tell the Dependency Injection Container to instantiate the controller class (and resolve all of its constructor dependencies).
- Use Reflection again to invoke the specific method.
- Box the return type into an
IActionResult. - Dispose of the controller class entirely.
This process requires significant memory allocation on the heap, forcing the Garbage Collector to work overtime.
Minimal APIs bypass almost all of this. There is no controller class to instantiate. The route is directly bound to a delegate function in memory. Dependencies are resolved instantly into the method signature. Because of this, Minimal APIs allocate significantly less memory per request and can handle roughly 20-30% more requests per second than traditional MVC Controllers (as proven by the TechEmpower Benchmarks).
4. Native AOT Compatibility
The performance gap gets even wider when you factor in .NET 8 Native AOT (Ahead-of-Time) compilation.
Native AOT compiles your C# application directly into machine code, resulting in ultra-fast startup times (perfect for AWS Lambdas) and microscopic memory footprints. However, Native AOT accomplishes this by aggressively stripping out dynamic Reflection at compile time.
Because traditional MVC Controllers rely so heavily on dynamic Reflection for routing and model binding, MVC Controllers are not fully compatible with Native AOT.
Minimal APIs, on the other hand, use C# Source Generators to analyze your routes at compile time and generate static code. If you want to deploy lightning-fast, Native AOT microservices, you must use Minimal APIs.
5. "But my Program.cs will be massive!" (How to Architect)
This is the absolute number one argument against Minimal APIs. If you put 150 API endpoints in your Program.cs file, it becomes an unmaintainable nightmare.
But you don't have to put them in Program.cs. You can easily group them using C# Extension Methods and the MapGroup feature.
I usually structure my Minimal APIs like this:
// In a separate file: Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
public static void MapProductEndpoints(this IEndpointRouteBuilder app)
{
// MapGroup allows you to apply routes, tags, and authorization globally
var group = app.MapGroup("/api/products").WithTags("Products");
group.MapGet("/{id}", GetProduct);
group.MapPost("/", CreateProduct);
group.MapDelete("/{id}", DeleteProduct);
}
private static async Task<IResult> GetProduct(int id, IProductService svc)
{
// ... logic
}
private static async Task<IResult> CreateProduct(ProductDto dto, IProductService svc)
{
// ... logic
}
}
Then in Program.cs, you just cleanly register the modules:
var builder = WebApplication.CreateBuilder(args);
// ... setup services ...
var app = builder.Build();
// Cleanly map the grouped endpoints
app.MapProductEndpoints();
app.MapUserEndpoints();
app.MapOrderEndpoints();
app.Run();
You get all the structural organization and file separation of MVC Controllers, but all the extreme performance benefits of Minimal APIs.
6. Third-Party Solutions: Carter
If even the Extension Method approach feels like too much manual wiring, the community has built fantastic libraries to automate the discovery of Minimal APIs.
The most popular is Carter. Carter allows you to implement an ICarterModule interface on your endpoint classes. On startup, Carter will automatically scan your assembly, find every class implementing that interface, and map the routes for you—completely mimicking the automatic discovery of MVC Controllers but maintaining the raw speed of Minimal APIs.
7. Filters and Middleware in Minimal APIs
Historically, developers stuck with Controllers because they loved [Authorize] attributes and custom Action Filters.
Minimal APIs fully support this now. You can apply authorization natively to route groups, and you can write Endpoint Filters that act exactly like Action Filters to intercept requests for validation or logging.
// Applying authorization to an entire group of endpoints instantly
app.MapGroup("/api/admin")
.RequireAuthorization("AdminPolicy")
.AddEndpointFilter<MyCustomValidationFilter>()
.MapGet("/users", GetUsers);
This is often much safer than traditional ASP.NET Core Middleware, because Endpoint Filters have direct access to the typed parameters being passed into the delegate, whereas Middleware only has access to the raw HTTP context.
Final Verdict
If you are building a legacy application that returns raw HTML using Razor Pages or MVC views, you should absolutely stick to Controllers.
But if you are building a standard JSON REST API, a headless microservice, or a highly concurrent backend, Minimal APIs are the clear, undeniable winner. They are vastly faster, they require significantly less boilerplate code, they enforce cleaner architectures, and they play beautifully with the cutting-edge Native AOT compilation features in .NET 8.
It is time to let go of the [ApiController].
Still handwriting your JSON request models? If you are building fast Minimal APIs, don't slow down your development speed by manually typing out C# DTOs to match your JSON payloads. Use my free JSON to C# Converter to instantly generate pristine, strongly-typed C# records and classes in seconds.