Minimal APIs vs MVC Controllers
Compare Minimal APIs with traditional MVC controllers, performance benchmarks, and use case scenarios
Minimal APIs vs MVC Controllers in ASP.NET
Master the architectural choice between Minimal APIs and MVC Controllers with free flashcards and interactive code examples. This lesson covers routing patterns, dependency injection, model binding, testing strategies, and performance considerationsβessential concepts for building modern ASP.NET applications with .NET 10.
Welcome to ASP.NET Architecture Patterns π»
Welcome to one of the most important architectural decisions you'll make when building ASP.NET applications! Understanding when to use Minimal APIs versus traditional MVC Controllers can dramatically impact your application's maintainability, performance, and developer experience.
In .NET 10, Microsoft has refined both approaches, making each one powerful in its own right. This lesson will equip you with the knowledge to choose the right pattern for your specific use case, understand the trade-offs, and implement both approaches effectively.
Core Concepts
π― What Are Minimal APIs?
Minimal APIs were introduced in .NET 6 as a lightweight, streamlined approach to building HTTP APIs. They eliminate much of the ceremony associated with traditional MVC controllers, allowing you to define endpoints directly in your Program.cs file or through extension methods.
Key characteristics:
- Reduced boilerplate: No need for controller classes, action methods, or attribute routing on classes
- Inline definitions: Route handlers can be defined as lambda expressions or local functions
- Performance: Slightly faster due to reduced abstraction layers
- Simplicity: Perfect for microservices, small APIs, and getting started quickly
ποΈ What Are MVC Controllers?
MVC (Model-View-Controller) Controllers have been the traditional approach in ASP.NET since its early days. They provide a structured, class-based architecture with clear separation of concerns.
Key characteristics:
- Structured organization: Controllers are classes containing related action methods
- Rich feature set: Built-in support for filters, model validation, content negotiation
- Convention-based: Follows established patterns that many developers know
- Testability: Easy to mock and unit test with dependency injection
π Feature Comparison
| Feature | Minimal APIs | MVC Controllers |
|---|---|---|
| Code Complexity | Low - inline handlers | Medium - class-based structure |
| Performance | β‘ Slightly faster | Very fast (negligible difference) |
| Organization | Can become cluttered at scale | β Excellent for large projects |
| Filters | Supported (endpoint filters) | β Rich filter pipeline |
| Model Binding | β Full support | β Full support |
| OpenAPI/Swagger | Requires manual configuration | β Automatic discovery |
| Learning Curve | β Gentle for beginners | Steeper (more concepts) |
| Best For | Microservices, simple APIs | Complex applications, teams |
π§ Routing Mechanisms
Minimal API Routing:
Routes are defined using methods like MapGet, MapPost, MapPut, MapDelete directly on the WebApplication instance:
app.MapGet("/api/products/{id}", (int id) => { ... });
app.MapPost("/api/products", (Product product) => { ... });
MVC Controller Routing:
Routes use attribute routing or convention-based routing:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetProduct(int id) { ... }
}
π Dependency Injection
Both approaches support dependency injection, but with different syntax:
Minimal APIs use parameter injection:
app.MapGet("/data", (IDataService service, ILogger<Program> logger) =>
{
logger.LogInformation("Fetching data");
return service.GetData();
});
MVC Controllers use constructor injection:
public class DataController : ControllerBase
{
private readonly IDataService _service;
private readonly ILogger<DataController> _logger;
public DataController(IDataService service, ILogger<DataController> logger)
{
_service = service;
_logger = logger;
}
}
π Model Binding and Validation
Both approaches support automatic model binding from various sources:
| Source | Minimal API | MVC Controller |
|---|---|---|
| Route | int id |
[FromRoute] int id |
| Query String | [FromQuery] string filter |
[FromQuery] string filter |
| Body | Product product |
[FromBody] Product product |
| Header | [FromHeader] string auth |
[FromHeader] string auth |
π‘ Pro Tip: In Minimal APIs, complex types from the body don't need the [FromBody] attributeβit's inferred automatically!
π‘οΈ Filters and Middleware
MVC Controllers have a rich filter pipeline:
- Authorization filters
- Resource filters
- Action filters
- Exception filters
- Result filters
Minimal APIs use endpoint filters (introduced in .NET 7):
app.MapGet("/api/products", () => { ... })
.AddEndpointFilter(async (context, next) =>
{
// Pre-processing
var result = await next(context);
// Post-processing
return result;
});
π§ Memory Aid: "MARS vs CLASS"
Minimal APIs = MARS π
- Microservices-friendly
- Agile and quick
- Reduced ceremony
- Simple structure
MVC Controllers = CLASS π
- Class-based organization
- Large application support
- Attribute-rich routing
- Structured and testable
- Scalable architecture
π€ Did You Know?
Minimal APIs can achieve up to 15-20% better throughput than equivalent MVC controller endpoints in high-load scenarios! However, this difference is only significant at extreme scale (tens of thousands of requests per second). For most applications, the performance difference is negligible compared to database queries and business logic.
π Real-World Analogy
Think of Minimal APIs as a food truck πβquick to set up, easy to move, perfect for focused menus, but space becomes limited as you grow.
MVC Controllers are like a restaurant π’βmore initial setup, structured kitchen and dining areas, can handle complex menus and large teams, scales better for big operations.
Detailed Examples
Example 1: Simple CRUD Operations
Minimal API Approach:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductRepository, ProductRepository>();
var app = builder.Build();
// GET all products
app.MapGet("/api/products", (IProductRepository repo) =>
repo.GetAll());
// GET product by ID
app.MapGet("/api/products/{id}", (int id, IProductRepository repo) =>
{
var product = repo.GetById(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
// POST new product
app.MapPost("/api/products", (Product product, IProductRepository repo) =>
{
repo.Add(product);
return Results.Created($"/api/products/{product.Id}", product);
});
// PUT update product
app.MapPut("/api/products/{id}", (int id, Product product, IProductRepository repo) =>
{
if (id != product.Id) return Results.BadRequest();
repo.Update(product);
return Results.NoContent();
});
// DELETE product
app.MapDelete("/api/products/{id}", (int id, IProductRepository repo) =>
{
repo.Delete(id);
return Results.NoContent();
});
app.Run();
MVC Controller Approach:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repository;
public ProductsController(IProductRepository repository)
{
_repository = repository;
}
[HttpGet]
public ActionResult<IEnumerable<Product>> GetAll()
{
return Ok(_repository.GetAll());
}
[HttpGet("{id}")]
public ActionResult<Product> GetById(int id)
{
var product = _repository.GetById(id);
if (product is null)
return NotFound();
return Ok(product);
}
[HttpPost]
public ActionResult<Product> Create(Product product)
{
_repository.Add(product);
return CreatedAtAction(nameof(GetById),
new { id = product.Id }, product);
}
[HttpPut("{id}")]
public IActionResult Update(int id, Product product)
{
if (id != product.Id)
return BadRequest();
_repository.Update(product);
return NoContent();
}
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
_repository.Delete(id);
return NoContent();
}
}
Analysis: For simple CRUD operations, Minimal APIs provide less ceremony. However, the MVC controller version is more organized and easier to navigate in a large codebase.
Example 2: Advanced Filtering and Validation
Minimal API with Endpoint Filters:
public class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (argument is null)
return Results.BadRequest("Invalid request data");
var validationResults = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(
argument,
new ValidationContext(argument),
validationResults,
validateAllProperties: true);
if (!isValid)
return Results.ValidationProblem(
validationResults.ToDictionary(
v => v.MemberNames.First(),
v => new[] { v.ErrorMessage ?? "Validation error" }));
return await next(context);
}
}
app.MapPost("/api/orders", async (Order order, IOrderService service) =>
{
var result = await service.CreateOrderAsync(order);
return Results.Created($"/api/orders/{result.Id}", result);
})
.AddEndpointFilter<ValidationFilter<Order>>()
.RequireAuthorization();
MVC Controller with Action Filters:
public class ValidateModelAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext context)
{
if (!context.ModelState.IsValid)
{
context.Result = new BadRequestObjectResult(context.ModelState);
}
}
}
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _service;
public OrdersController(IOrderService service)
{
_service = service;
}
[HttpPost]
[ValidateModel]
[Authorize]
public async Task<ActionResult<OrderResult>> CreateOrder(Order order)
{
var result = await _service.CreateOrderAsync(order);
return CreatedAtAction(nameof(GetOrder),
new { id = result.Id }, result);
}
[HttpGet("{id}")]
public async Task<ActionResult<OrderResult>> GetOrder(int id)
{
var order = await _service.GetOrderAsync(id);
if (order is null)
return NotFound();
return Ok(order);
}
}
Analysis: MVC Controllers have more mature support for filters with attributes. The [ApiController] attribute automatically validates models and returns 400 responses. Minimal APIs require more manual setup but offer fine-grained control.
Example 3: Organizing Minimal APIs at Scale
As your Minimal API grows, you should organize endpoints into static classes:
public static class ProductEndpoints
{
public static void MapProductEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/products")
.WithTags("Products")
.RequireAuthorization();
group.MapGet("/", GetAll)
.WithName("GetAllProducts")
.Produces<List<Product>>();
group.MapGet("/{id}", GetById)
.WithName("GetProductById")
.Produces<Product>()
.Produces(404);
group.MapPost("/", Create)
.WithName("CreateProduct")
.Produces<Product>(201)
.ProducesValidationProblem();
}
private static async Task<IResult> GetAll(IProductRepository repo)
{
var products = await repo.GetAllAsync();
return Results.Ok(products);
}
private static async Task<IResult> GetById(int id, IProductRepository repo)
{
var product = await repo.GetByIdAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
}
private static async Task<IResult> Create(
Product product,
IProductRepository repo,
IValidator<Product> validator)
{
var validationResult = await validator.ValidateAsync(product);
if (!validationResult.IsValid)
return Results.ValidationProblem(validationResult.ToDictionary());
await repo.AddAsync(product);
return Results.Created($"/api/products/{product.Id}", product);
}
}
// In Program.cs:
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapCustomerEndpoints();
Analysis: This pattern, sometimes called "Vertical Slice Architecture," keeps Minimal APIs organized while maintaining their lightweight nature. Each feature has its own file with related endpoints grouped together.
Example 4: Testing Strategies
Testing Minimal APIs:
public class ProductEndpointsTests
{
[Fact]
public async Task GetById_ReturnsProduct_WhenProductExists()
{
// Arrange
var factory = new WebApplicationFactory<Program>
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddSingleton<IProductRepository>(
new MockProductRepository());
});
});
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/api/products/1");
// Assert
response.EnsureSuccessStatusCode();
var product = await response.Content
.ReadFromJsonAsync<Product>();
Assert.NotNull(product);
Assert.Equal(1, product.Id);
}
}
Testing MVC Controllers:
public class ProductsControllerTests
{
[Fact]
public async Task GetById_ReturnsOkResult_WhenProductExists()
{
// Arrange
var mockRepo = new Mock<IProductRepository>();
mockRepo.Setup(r => r.GetByIdAsync(1))
.ReturnsAsync(new Product { Id = 1, Name = "Test" });
var controller = new ProductsController(mockRepo.Object);
// Act
var result = await controller.GetById(1);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var product = Assert.IsType<Product>(okResult.Value);
Assert.Equal(1, product.Id);
}
}
Analysis: MVC Controllers are easier to unit test because they're just classes. Minimal APIs typically require integration testing with WebApplicationFactory, which is more comprehensive but slower.
π§ Try This: Migration Exercise
Take this MVC controller and convert it to Minimal API syntax:
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IUserService _service;
public UsersController(IUserService service) => _service = service;
[HttpGet("{id}")]
[Authorize]
public async Task<ActionResult<User>> Get(int id)
{
var user = await _service.GetUserAsync(id);
return user is null ? NotFound() : Ok(user);
}
}
Click to see solution
app.MapGet("/api/users/{id}", async (int id, IUserService service) =>
{
var user = await service.GetUserAsync(id);
return user is null ? Results.NotFound() : Results.Ok(user);
})
.RequireAuthorization();
β οΈ Common Mistakes
Mistake 1: Putting All Minimal API Routes in Program.cs
β Wrong:
var app = builder.Build();
// 200 lines of endpoint definitions here...
app.MapGet("/api/products", ...);
app.MapPost("/api/products", ...);
app.MapGet("/api/orders", ...);
// ... 50 more endpoints
app.Run();
β Correct:
var app = builder.Build();
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapCustomerEndpoints();
app.Run();
Use extension methods and separate files to organize endpoints logically!
Mistake 2: Not Using Route Groups
β Wrong:
app.MapGet("/api/products", ...).RequireAuthorization();
app.MapGet("/api/products/{id}", ...).RequireAuthorization();
app.MapPost("/api/products", ...).RequireAuthorization();
app.MapPut("/api/products/{id}", ...).RequireAuthorization();
β Correct:
var products = app.MapGroup("/api/products")
.RequireAuthorization()
.WithTags("Products");
products.MapGet("/", ...);
products.MapGet("/{id}", ...);
products.MapPost("/", ...);
products.MapPut("/{id}", ...);
Mistake 3: Forgetting Async/Await in Minimal APIs
β Wrong:
app.MapGet("/api/data", (IDataService service) =>
{
var data = service.GetDataAsync(); // Returns Task<Data>
return Results.Ok(data); // Returns Task wrapper, not data!
});
β Correct:
app.MapGet("/api/data", async (IDataService service) =>
{
var data = await service.GetDataAsync();
return Results.Ok(data);
});
Mistake 4: Not Specifying OpenAPI Metadata
β Wrong:
app.MapPost("/api/products", (Product product, IRepository repo) =>
{
repo.Add(product);
return Results.Created($"/api/products/{product.Id}", product);
});
// Swagger/OpenAPI won't know request/response types!
β Correct:
app.MapPost("/api/products", (Product product, IRepository repo) =>
{
repo.Add(product);
return Results.Created($"/api/products/{product.Id}", product);
})
.Produces<Product>(201)
.ProducesValidationProblem()
.WithDescription("Creates a new product");
Mistake 5: Over-Engineering Simple APIs with MVC
β Wrong (for a simple 3-endpoint microservice):
[ApiController]
[Route("api/[controller]")]
public class HealthController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(new { status = "healthy" });
}
β Correct:
app.MapGet("/health", () => new { status = "healthy" });
For simple scenarios, embrace simplicity!
Mistake 6: Inconsistent Return Types in Minimal APIs
β Wrong:
app.MapGet("/api/items/{id}", (int id, IRepository repo) =>
{
var item = repo.GetById(id);
if (item is null)
return null; // Type inconsistency!
return item;
});
β Correct:
app.MapGet("/api/items/{id}", (int id, IRepository repo) =>
{
var item = repo.GetById(id);
return item is null ? Results.NotFound() : Results.Ok(item);
});
Always return IResult types for consistency and proper HTTP status codes.
π― Key Takeaways
Choose Minimal APIs for: Microservices, simple APIs, rapid prototyping, serverless functions, and small teams
Choose MVC Controllers for: Large applications, established teams, complex business logic, when you need extensive filter pipelines, and better tooling support
Performance difference is minimal: Only matters at extreme scale (tens of thousands of requests/second)
Both support modern features: Dependency injection, model binding, validation, authentication, and authorization work similarly
Organization matters: Use route groups and extension methods to keep Minimal APIs maintainable
Testing differs: MVC Controllers are easier to unit test; Minimal APIs typically need integration tests
You can mix both: Use Minimal APIs for simple endpoints and MVC Controllers for complex features in the same application
OpenAPI support: MVC Controllers have better automatic documentation; Minimal APIs need explicit metadata
π‘ Final Recommendation: Start new microservices and simple APIs with Minimal APIs. Use MVC Controllers for complex, enterprise-scale applications or when your team already has established MVC patterns. For hybrid scenarios, leverage both!
π Further Study
- Microsoft Official Docs - Minimal APIs Overview: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview
- Microsoft Official Docs - MVC Controllers: https://learn.microsoft.com/en-us/aspnet/core/web-api/
- ASP.NET Core Performance Best Practices: https://learn.microsoft.com/en-us/aspnet/core/performance/performance-best-practices
π Quick Reference Card
| Aspect | Minimal APIs | MVC Controllers |
|---|---|---|
| Syntax | app.MapGet("/path", handler) |
[HttpGet] public IActionResult Method() |
| DI | Parameter injection | Constructor injection |
| Organization | Extension methods + groups | Controller classes |
| Return Types | Results.Ok(), Results.NotFound() |
Ok(), NotFound() |
| Filters | .AddEndpointFilter<T>() |
[FilterAttribute] |
| Routing | Explicit in MapXxx methods | Attribute-based |
| Testing | Integration tests (WebApplicationFactory) | Unit tests (mock dependencies) |
| Best For | π Speed, simplicity, microservices | π’ Structure, scale, teams |