You are viewing a preview of this lesson. Sign in to start learning
Back to ASP.NET with .NET 10

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

  1. Choose Minimal APIs for: Microservices, simple APIs, rapid prototyping, serverless functions, and small teams

  2. Choose MVC Controllers for: Large applications, established teams, complex business logic, when you need extensive filter pipelines, and better tooling support

  3. Performance difference is minimal: Only matters at extreme scale (tens of thousands of requests/second)

  4. Both support modern features: Dependency injection, model binding, validation, authentication, and authorization work similarly

  5. Organization matters: Use route groups and extension methods to keep Minimal APIs maintainable

  6. Testing differs: MVC Controllers are easier to unit test; Minimal APIs typically need integration tests

  7. You can mix both: Use Minimal APIs for simple endpoints and MVC Controllers for complex features in the same application

  8. 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

  1. Microsoft Official Docs - Minimal APIs Overview: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview
  2. Microsoft Official Docs - MVC Controllers: https://learn.microsoft.com/en-us/aspnet/core/web-api/
  3. 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

Practice Questions

Test your understanding with these questions:

Q1: What namespace contains the Results helper class used in Minimal APIs to return HTTP responses like Results.Ok() and Results.NotFound()?
A: Results
Q2: Complete the Minimal API route definition: ```csharp app.{{1}}("/api/users/{id}", async (int id, IUserService service) => { var user = await service.GetUserAsync(id); return Results.Ok(user); }); ```
A: ["MapGet"]
Q3: Which statement about performance differences between Minimal APIs and MVC Controllers is most accurate? A. MVC Controllers are 50% faster due to better optimization B. Minimal APIs are slightly faster but the difference is negligible for most applications C. MVC Controllers have significantly better throughput under load D. Performance is identical because they use the same underlying routing E. Minimal APIs are 10x faster and should always be preferred
A: B
Q4: What does this MVC Controller action return? ```csharp [HttpGet("{id}")] public async Task<ActionResult<Product>> GetProduct(int id) { var product = await _repository.GetByIdAsync(id); if (product is null) return NotFound(); return Ok(product); } ``` A. Always returns null if product doesn't exist B. Returns 200 with product or 404 if not found C. Returns 500 error if product is null D. Returns 400 bad request for invalid IDs E. Returns the raw product object without status code
A: B
Q5: In MVC Controllers, what attribute automatically validates model state and returns 400 responses for invalid models?
A: ApiController