In this article explores the differences between controllers and new minimal api, analyzing the advantages and disadvantages of both to help you choose the option best suited to your project.
If you’re already a .NET developer with experience in traditional controllers but haven’t yet explored Minimal APIs, this article is specifically designed for you.
What are Minimal APIs?
Minimal APIs, introduced with .NET 6, represent a simplified approach to creating web APIs in ASP.NET Core. As the name suggests, they’re designed to minimize the boilerplate code and dependencies needed to create a functional API.
Here’s a simple example of a Minimal API that responds to a GET request:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/hello", () => "Hello, World!");
app.Run();With these few lines of code, we’ve created a fully functional API that responds to GET requests on the /hello endpoint returning the string “Hello, World!”.
Controller vs minimal API: the fundamental differences
Code structure
Controller
[ApiController]
[Route("api/[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray();
}
}
Minimal API
app.MapGet("/weatherforecast", () =>
{
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
var rng = new Random();
var forecast = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = summaries[rng.Next(summaries.Length)]
})
.ToArray();
return forecast;
});
Configuration and startup
Controller:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
// Other configurations...
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Minimal API
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add services
var app = builder.Build();
// Configure middleware
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Define routes
app.MapGet("/products", () => /* ... */);
app.MapPost("/products", (Product product) => /* ... */);
app.Run();
The advantages of minimal APIs
Less boilerplate code
Minimal APIs significantly reduce the amount of code needed to create an API, you can even insert them in Program.cs (although this isn’t a best practice, but we’ll see that later in this article). When we create a new .NET API project from Visual Studio, minimal APIs are the default option.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
The previous code represents a complete web API application, hard to be more minimalist than that!
Simpler and faster startup
A Minimal API application can be started more quickly because it loads fewer dependencies compared to a full MVC-based application (also from a RAM perspective the consumption is lower).
Performance
Minimal APIs were designed with performance in mind. By removing the unnecessary parts of the MVC framework, you get a lighter application with less overhead. This aspect is very important to Microsoft, which is focusing heavily on this. With .NET 9, memory allocations per request in Minimal APIs were reduced by up to 93%. Every new version of .NET is always faster than the previous one, thanks also to improvements to JIT or AOT compilation.
Native AOT
Minimal APIs have native support for Ahead-of-Time (AOT) compilation, unlike classic controllers which don’t support it. This makes them the go-to choice in scenarios where cold start and memory consumption are critical, such as serverless functions or containers with limited resources.
Validation
With .NET 10, built-in validation was added (in previous versions validation had to be done manually, implementing specific checks in the methods or using third-party libraries like FluentValidation).
// Enable validation for all endpoints
builder.Services.AddValidation();
public record Product(
[Required] string Name,
[Range(1, 1000)] int Quantity,
[EmailAddress] string? ContactEmail);
app.MapPost("/products", (Product product) => TypedResults.Created($"/products/{product.Name}", product));
// Disable validation on a specific endpoint
app.MapPost("/products-import", (ProductBatch batch) => TypedResults.Ok())
.DisableValidation();
Functional programming
Minimal APIs encourage a more functional programming style, using lambdas and functions instead of classes and methods, which can lead to more concise code for simple APIs.
app.MapGet("/user/{id}", (int id) => GetUser(id));
app.MapPost("/user", (User user) => CreateUser(user));
app.MapPut("/user/{id}", (int id, User user) => UpdateUser(id, user));
app.MapDelete("/user/{id}", (int id) => DeleteUser(id));
Limitations of minimal APIs
Despite the advantages, Minimal APIs have some limitations that are important to know:
Less structure for complex projects
For large-scale projects, the lack of a formal structure like the one provided by controllers can lead to disorganized or hard-to-maintain code. My recommendation is not to insert endpoints directly in Program.cs but to always create dedicated extension classes, where you can divide and organize the endpoints.
No native support for Action Filters
Minimal APIs don’t support MVC controller Action Filters. Instead, you use IEndpointFilter, which offers similar functionality but with a different syntax. This is worth keeping in mind if you’re migrating an existing project that makes heavy use of filters.
Best practices when using minimal APIs
If you decide to use Minimal APIs, here are some best practices to follow:
1. Organize code in separate files
Minimal APIs can be written directly in Program.cs, but it’s not good practice to write everything in here. A well-organized project is an essential requirement, so minimal APIs should be organized in dedicated classes:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapProductEndpoints();
app.Run();
public static class ProductEndpoints
{
public static WebApplication MapProductEndpoints(this WebApplication app)
{
app.MapGet("/products", GetAllProducts);
app.MapGet("/products/{id}", GetProductById);
app.MapPost("/products", CreateProduct);
return app;
}
private static IResult GetAllProducts() => /* ... */;
private static IResult GetProductById(int id) => /* ... */;
private static IResult CreateProduct(Product product) => /* ... */;
}
2. Use endpoint groups
Starting from .NET version 7 you can organize your APIs in groups. With this approach you can add ad-hoc features based on the group (e.g., RequireAuthorization()) instead of repeating it for every mapped endpoint.
var productsGroup = app.MapGroup("/products")
.WithTags("Products") // OpenAPI tag
.RequireAuthorization(); // Apply authorization to all routes in the group
productsGroup.MapGet("/", GetAllProducts);
productsGroup.MapGet("/{id}", GetProductById);
productsGroup.MapPost("/", CreateProduct);
productsGroup.MapPut("/{id}", UpdateProduct);
productsGroup.MapDelete("/{id}", DeleteProduct);
3. Model validation
There are several options for model validation in Minimal APIs:
Option 1: Out-of-the-box validation (.NET 10+)
Starting from .NET 10, you can enable native validation with a single line, using the same data annotation attributes already familiar from classic controllers:
builder.Services.AddValidation();
app.MapPost("/products", (Product product) => TypedResults.Created($"/products/{product.Name}", product));
Option 2: Using FluentValidation (install the NuGet package)
I talked about the Fluent library in this article.
app.MapPost("/products", async (IValidator<Product> validator, Product product) =>
{
var validationResult = await validator.ValidateAsync(product);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
// Creation logic
// [...]
});
4. Dependency Injection
Dependency injection works out-of-the-box with Minimal APIs, you just need to call the interface as a parameter, similar to how we already do in class constructors.
app.MapGet("/products",
async (IProductRepository repository) =>
{
return await repository.GetAllProductsAsync();
});
app.MapGet("/products/{id}",
async (int id, IProductRepository repository) =>
{
var product = await repository.GetProductByIdAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
When to use minimal APIs?
We’ve seen the pros and cons, the best practices and how to use them… but when should we choose this approach over classic controllers?
- Microservices: When you need lightweight services with a limited number of endpoints.
- Simple APIs: For APIs with few endpoints and simple logic. In case of excessive complexity prefer classic controllers or centralize in dedicated services (to be injected via dependency injection).
- Rapid prototyping: When you need to quickly create a functional API.
- Native AOT and serverless: Minimal APIs are the only choice when compiling with AOT or working in serverless environments where cold start is critical. MVC controllers don’t support Native AOT.
- Educational projects: Great for learning and demonstrating REST API concepts without the overhead of MVC conventions. In the case of enterprise projects, minimal APIs should always be considered based on the complexity and organization of the project.
Usage example
Let’s see a practical comparison implementing the same API in both styles, assuming we need to create a series of endpoints to manage a product catalog.
Controller approach:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repository;
public ProductsController(IProductRepository repository)
{
_repository = repository;
}
/// <summary>
/// Get products
/// </summary>
[HttpGet]
public async Task<ActionResult<IEnumerable<Product>>> GetProducts()
{
var products = await _repository.GetAllAsync();
return Ok(products);
}
/// <summary>
/// Get product
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id)
{
var product = await _repository.GetByIdAsync(id);
if (product == null)
{
return NotFound();
}
return product;
}
/// <summary>
/// Create product
/// </summary>
[HttpPost]
public async Task<ActionResult<Product>> CreateProduct(Product product)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await _repository.AddAsync(product);
return CreatedAtAction(
nameof(GetProduct),
new { id = product.Id },
product);
}
/// <summary>
/// Update product
/// </summary>
[HttpPut("{id}")]
public async Task<IActionResult> UpdateProduct(int id, Product product)
{
if (id != product.Id)
{
return BadRequest();
}
var exists = await _repository.ExistsAsync(id);
if (!exists)
{
return NotFound();
}
await _repository.UpdateAsync(product);
return NoContent();
}
/// <summary>
/// Delete product
/// </summary>
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id)
{
var exists = await _repository.ExistsAsync(id);
if (!exists)
{
return NotFound();
}
await _repository.DeleteAsync(id);
return NoContent();
}
}
Minimal API approach:
For simplification purposes everything is inside Program.cs, the endpoints should still be extracted and placed in a dedicated class, as seen earlier in this tutorial.
var builder = WebApplication.CreateBuilder(args);
// Native OpenAPI support (available from .NET 9, no Swashbuckle required)
builder.Services.AddOpenApi();
// DI registration
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
// Exposes the OpenAPI document at /openapi/v1.json
app.MapOpenApi();
// Optional: add a UI like Scalar (Scalar.AspNetCore package)
// app.MapScalarApiReference();
}
app.UseHttpsRedirection();
// Map your endpoints keeping Program.cs clean
app.MapProductEndpoints();
app.Run();
Above is the Program.cs, below are the endpoints centralized in a dedicated class:
public static class ProductEndpoints
{
public static WebApplication MapProductEndpoints(this WebApplication app) {
var group = app.MapGroup("/api/products")
.WithTags("Products");
group.MapGet("/", GetAllProductsAsync)
.WithName("GetProducts");
group.MapGet("/{id}", GetProductByIdAsync)
.WithName("GetProduct");
group.MapPost("/", CreateProductAsync)
.WithName("CreateProduct");
group.MapPut("/{id}", UpdateProductAsync)
.WithName("UpdateProduct");
group.MapDelete("/{id}", DeleteProductAsync)
.WithName("DeleteProduct");
return app;
}
// ****************************
// GET products
// ****************************
private static async Task<IResult> GetAllProductsAsync(IProductRepository repository)
{
return Results.Ok(await repository.GetAllAsync());
}
// ****************************
// GET product by ID
// ****************************
private static async Task<IResult> GetProductByIdAsync(int id, IProductRepository repository)
{
var product = await repository.GetByIdAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
}
// ****************************
// Create product
// ****************************
private static async Task<IResult> CreateProductAsync(Product product, IProductRepository repository)
{
await repository.AddAsync(product);
return Results.CreatedAtRoute("GetProduct", new { id = product.Id }, product);
}
// ****************************
// Update product
// ****************************
private static async Task<IResult> UpdateProductAsync(int id, Product product, IProductRepository repository)
{
if (id != product.Id) {
return Results.BadRequest();
}
var exists = await repository.ExistsAsync(id);
if (!exists) {
return Results.NotFound();
}
await repository.UpdateAsync(product);
return Results.NoContent();
}
// ****************************
// Delete product
// ****************************
private static async Task<IResult> DeleteProductAsync(int id, IProductRepository repository)
{
var exists = await repository.ExistsAsync(id);
if (!exists)
{
return Results.NotFound();
}
await repository.DeleteAsync(id);
return Results.NoContent();
}
}
Conclusion
Minimal APIs represent an interesting evolution in web development with .NET, offering a leaner and more direct approach compared to using classic controllers. However, they’re not a universal solution, the choice between minimal APIs and traditional controllers depends on the specific needs of your project.
It’s been several .NET versions since minimal APIs were introduced, and it’s already the standard when we create a new web API project. My recommendation is to start experimenting with minimal APIs in small-scale projects: this will allow you to evaluate if and how this new approach fits your development style and your team’s needs, but it also allows you to start familiarizing yourself with something that will increasingly become the norm in projects of this type.