Minimal APIs vs MVC Controllers
Compare Minimal APIs with traditional MVC controllers, performance benchmarks, and use case scenarios
ACTIVE PRACTICE ยท 15 practice questions
Make this lesson stick.
Test what you can recall and learn from the feedback. Come back to the lesson whenever you need an explanation.
Sign in to practice โ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 since ASP.NET MVC arrived in 2009. 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 |
| 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 | โ
Automatic discovery (types inferred from parameters and TypedResults) |
โ
Automatic discovery (types inferred from ActionResult<T>) |
| 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 on the WebApplication instance (or any IEndpointRouteBuilder):
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 |
int id (inferred under [ApiController]) |
| Query String | [FromQuery] string filter |
[FromQuery] string filter |
| Body | Product product |
Product product (inferred under [ApiController]) |
| Header | [FromHeader] string auth |
[FromHeader] string auth |
๐ก Pro Tip: In both styles, complex types are bound from the body without [FromBody]โMinimal APIs infer it always, and MVC infers it under [ApiController]. Reach for [FromRoute], [FromQuery], [FromBody] only to override the inference.
๐ก๏ธ 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 show measurably better throughput than equivalent MVC controller endpoints in micro-benchmarksโfigures in the 10-20% range are often quoted, but the gap depends heavily on workload and .NET version. 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
.SelectMany(v => v.MemberNames.DefaultIfEmpty(string.Empty),
(v, member) => (member, error: v.ErrorMessage ?? "Validation error"))
.GroupBy(x => x.member)
.ToDictionary(g => g.Key, g => g.Select(x => x.error).ToArray()));
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:
// Note: under [ApiController] the built-in ModelStateInvalidFilter already
// returns 400 before any action filter runs. This custom filter only takes
// over if you set SuppressModelStateInvalidFilter = true in
// AddControllers().ConfigureApiBehaviorOptions(...).
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 a mature attribute-based filter pipeline, and [ApiController] validates models and returns 400 responses automatically. Since .NET 10, Minimal APIs get the same built-in DataAnnotations validation by calling builder.Services.AddValidation()โthe hand-rolled ValidationFilter<T> above shows what happens underneath and is still the tool for custom validation logic.
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)
{
// Invalid products never reach here: builder.Services.AddValidation()
// (.NET 10) runs DataAnnotations checks and returns 400 first.
await repo.AddAsync(product);
return Results.Created($"/api/products/{product.Id}", product);
}
}
// In Program.cs:
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapCustomerEndpoints();
Analysis: This feature-file pattern (a lightweight cousin of 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
// Program.cs needs `public partial class Program { }` so the
// test project can reference the top-level-statements Program.
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 void GetById_ReturnsOkResult_WhenProductExists()
{
// Arrange
var mockRepo = new Mock<IProductRepository>();
mockRepo.Setup(r => r.GetById(1))
.Returns(new Product { Id = 1, Name = "Test" });
var controller = new ProductsController(mockRepo.Object);
// Act
var result = 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 plain classes, so unit testing them is natural. Inline Minimal API lambdas are hard to reach, but the static handler methods from Example 3 are just as unit-testableโmake them internal, call them with a fake repository, and assert on the returned IResult. Integration tests with WebApplicationFactory exercise the full pipeline for either style and are slower but more comprehensive.
๐ง 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); // Serializing a Task<Data> throws โ a 500, not your 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);
});
// The request body (Product) is inferred, but Results.Created returns an
// untyped IResult, so OpenAPI cannot know the 201 response type!
โ 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");
// Or let the types speak for themselves: return TypedResults.Created(...)
// from a handler typed as Results<Created<Product>, ValidationProblem>
// and the response metadata is inferred with no extra calls.
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; // 200 OK with a null body instead of 404!
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);
});
Return IResult (or TypedResults) whenever an endpoint can produce more than one status code. A handler that always succeeds, like the health check above, may return the object directly.
๐ฏ 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 slightly: controllers and extracted Minimal API handler methods both unit test; inline lambdas need integration tests via WebApplicationFactory
You can mix both: Use Minimal APIs for simple endpoints and MVC Controllers for complex features in the same application
OpenAPI support:
Microsoft.AspNetCore.OpenApidiscovers both styles automatically in .NET 10; when a handler returns untypedIResultorIActionResult, declare response types with.Produces<T>()or[ProducesResponseType], or useTypedResults
๐ก 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 or convention-based |
| Testing | Unit tests on extracted handlers, or WebApplicationFactory | Unit tests (mock dependencies) |
| Best For | ๐ Speed, simplicity, microservices | ๐ข Structure, scale, teams |