Controllers vs Minimal APIs
15 questions foundController-based APIs organize endpoints as methods within controller classes (inheriting ControllerBase), using attributes ([HttpGet], [Route]) to define routes, relying on the MVC framework's action invocation pipeline. Minimal APIs define endpoints as direct lambda or method group registrations via app.MapGet/MapPost/etc. directly in Program.cs (or organized into extension methods), without requiring a full controller class, filters pipeline, or the MVC framework's heavier abstractions.
// Controller-based
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase {
[HttpGet("{id}")]
public IActionResult Get(int id) => Ok(_service.GetById(id));
}
// Minimal API equivalent
app.MapGet("/api/products/{id}", (int id, IProductService service) => service.GetById(id));
Real-world example A team building a small, focused microservice with only five endpoints chooses minimal APIs for their reduced ceremony, while a large, complex API with dozens of related endpoints and shared cross-cutting filter logic sticks with controllers.
Common follow-ups: What performance difference exists between the two approaches?;Can you mix both controllers and minimal APIs in the same project?
Endpoint Metadata Route Constraints & Templates;Filters
Controllers provide built-in support for action filters/exception filters/result filters (a rich, multi-stage filter pipeline), automatic model validation via [ApiController]'s ModelState checking, convention-based routing, and view rendering (for MVC views/Razor Pages) -- minimal APIs instead use the lighter-weight IEndpointFilter for cross-cutting concerns and require more explicit, manual validation logic, reflecting a deliberate design trade-off favoring simplicity and lower overhead over built-in richness.
// Controllers: automatic model validation via [ApiController]
[ApiController]
public class ProductsController : ControllerBase {
[HttpPost]
public IActionResult Create(ProductDto dto) {
// ModelState.IsValid already checked automatically, 400 returned if invalid
return Ok(_service.Create(dto));
}
}
// Minimal API: validation typically requires explicit logic or a library like FluentValidation
Real-world example A team migrating a validation-heavy controller-based API to minimal APIs discovers they need to add explicit validation middleware or a library like MinimalApis.Extensions to replicate the automatic ModelState validation they previously got for free with [ApiController].
Common follow-ups: How does IEndpointFilter provide similar functionality to MVC's filter pipeline?;What third-party libraries fill the validation gap for minimal APIs?
Model Binding & Validation;Filters
Minimal APIs generally have lower per-request overhead since they bypass much of the MVC framework's action invocation machinery (the filter pipeline resolution, model binding via ModelState, action selection reflection) that controllers go through -- benchmarks typically show minimal APIs achieving somewhat higher requests-per-second and lower latency for simple endpoints, though for complex endpoints with heavy business logic, the framework overhead difference becomes a smaller fraction of total request time and matters less in practice.
// Minimal APIs: more direct execution path
app.MapGet("/ping", () => "pong"); // minimal framework overhead
// Controllers: more machinery invoked (filters, model binding, action selection)
[HttpGet("ping")]
public IActionResult Ping() => Ok("pong");
Real-world example A high-throughput, latency-sensitive gateway service migrates its simplest pass-through endpoints from controllers to minimal APIs after benchmarking showed a measurable throughput improvement, while keeping complex business-logic-heavy endpoints on controllers where the overhead difference was negligible.
Common follow-ups: At what request complexity does the framework overhead difference become negligible?;What specific MVC pipeline stages does a minimal API bypass?
Diagnostics & Performance;Endpoint Metadata Route Constraints & Templates
Common patterns include extension methods grouping related endpoint registrations (e.g., MapProductEndpoints(this WebApplication app)) called once from Program.cs, or the newer RouteGroupBuilder (via app.MapGroup("/products")) letting you apply shared prefixes, filters, and metadata to a cohesive group of related endpoints defined in a separate file -- both approaches keep Program.cs concise while maintaining minimal API's lightweight style.
// ProductEndpoints.cs
public static class ProductEndpoints {
public static void MapProductEndpoints(this WebApplication app) {
var group = app.MapGroup("/api/products").RequireAuthorization();
group.MapGet("/", GetAll);
group.MapGet("/{id}", GetById);
group.MapPost("/", Create);
}
}
// Program.cs
app.MapProductEndpoints();
Real-world example A minimal-API-based project with 60+ endpoints organizes them into a dozen feature-specific extension method files (MapProductEndpoints, MapOrderEndpoints, etc.), keeping Program.cs to a handful of clean, readable registration calls.
Common follow-ups: How does MapGroup let you apply shared authorization or filters to an entire group at once?;What naming/organizational conventions work best for large minimal API codebases?
Endpoint Metadata Route Constraints & Templates;Authorization
Consider: team familiarity (existing MVC expertise favors controllers, reducing onboarding friction), project complexity (many cross-cutting concerns like complex filters, view rendering, or extensive convention-based routing favor controllers' richer built-in tooling), performance sensitivity (minimal APIs for latency-critical, high-throughput services), and codebase size (minimal APIs' lower ceremony suits smaller, focused services well but can become unwieldy without discipline at very large scale) -- there's no universally correct choice, and many organizations use both across different services based on each service's specific needs.
// Decision framework applied:
// - Public-facing, complex e-commerce API with dozens of related resources, filters, views -> Controllers
// - Internal, narrow, high-throughput pricing microservice with 5 endpoints -> Minimal APIs
// - Team deeply experienced with MVC, tight deadline -> Controllers (familiarity wins)
Real-world example An organization standardizes on minimal APIs for new, narrowly-scoped microservices while maintaining existing large MVC-based applications as controllers, pragmatically choosing per-project rather than mandating one approach organization-wide.
Common follow-ups: What signals suggest a minimal API project has grown complex enough to benefit from migrating to controllers?;How do teams that use both approaches keep conventions consistent across services?
Endpoint Metadata Route Constraints & Templates;Testing ASP.NET Core Applications
Controllers require explicit binding source attributes ([FromBody], [FromQuery], [FromRoute]) in ambiguous cases, though [ApiController] provides some automatic inference. Minimal APIs infer binding sources more aggressively by default based on parameter type and route pattern matching (a parameter matching a route template segment binds from the route, a complex type typically binds from the body), reducing the need for explicit attributes in common cases while still supporting them for disambiguation when needed.
// Minimal API: automatic inference based on parameter characteristics
app.MapPost("/products/{id}", (int id, ProductDto dto, [FromServices] IProductService service) => {
// id inferred from route, dto inferred from body, service explicitly from DI
return service.Update(id, dto);
});
Real-world example A minimal API endpoint binds its route parameter, request body, and injected service all through inference and one explicit [FromServices] attribute, achieving the same result as a controller action with more verbose explicit attributes on every parameter.
Common follow-ups: When does minimal API's automatic inference get it wrong, requiring explicit attributes?;How does model binding performance compare between the two approaches?
Model Binding & Validation;Dependency Injection
Since minimal APIs lack [ApiController]'s automatic ModelState validation, you implement it via a custom IEndpointFilter that validates the incoming DTO (often using DataAnnotations' Validator class or a library like FluentValidation) before the endpoint handler runs, returning a 400 response with validation errors if invalid -- applied globally to a route group via .AddEndpointFilter() rather than needing per-action boilerplate.
public class ValidationFilter<T> : IEndpointFilter {
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) {
var dto = context.GetArgument<T>(0);
var validationResults = new List<ValidationResult>();
if (!Validator.TryValidateObject(dto, new ValidationContext(dto), validationResults, true))
return Results.ValidationProblem(validationResults.ToDictionary(...));
return await next(context);
}
}
app.MapPost("/products", Create).AddEndpointFilter<ValidationFilter<ProductDto>>();
Real-world example A team building a large minimal-API-based project writes one reusable generic ValidationFilter<T> applied to every endpoint accepting a DTO, replicating [ApiController]'s automatic validation behavior consistently without per-endpoint duplication.
Common follow-ups: How does this compare in complexity/maintainability to just using controllers with [ApiController]?;What other MVC conveniences commonly need to be reimplemented for minimal APIs?
Model Binding & Validation;Filters
Yes -- app.MapControllers() and individual app.MapGet/MapPost calls can coexist in the same Program.cs without conflict, as long as their route patterns don't overlap ambiguously. Valid reasons include incrementally migrating a controller-based application to minimal APIs feature-by-feature, or using minimal APIs specifically for a small number of simple, high-throughput endpoints while keeping complex, filter-heavy business logic on controllers within the same overall application.
var app = builder.Build();
app.MapControllers(); // existing, complex business logic endpoints
app.MapGet("/health", () => Results.Ok("healthy")); // simple, high-frequency endpoint as minimal API
app.MapGet("/version", () => Results.Ok(new { version = "1.4.2" }));
Real-world example A large e-commerce API keeps its complex order-processing logic on controllers (benefiting from the filter pipeline) while adding new, simple, frequently-polled health and status endpoints as minimal APIs for their lower overhead.
Common follow-ups: What routing conflicts can arise when mixing both approaches in the same URL space?;Is this hybrid approach considered a maintainability risk long-term?
Health Checks;Endpoint Metadata Route Constraints & Templates
Controllers exclusively use constructor injection, resolving all dependencies once when the controller instance is created per-request. Minimal APIs support injecting services directly as handler delegate parameters (automatically inferred as [FromServices] for registered service types not matching route/body binding patterns), letting each individual endpoint declare exactly the specific dependencies it needs without a shared constructor across multiple actions, which can reduce unnecessary coupling when different endpoints in the same feature area need different subsets of dependencies.
// Controller: all actions share the same constructor-injected dependencies
public class ProductsController(IProductService service, ILogger<ProductsController> logger) : ControllerBase {
[HttpGet] public IActionResult GetAll() => Ok(service.GetAll()); // has logger even if unused here
}
// Minimal API: each endpoint declares only what it actually needs
app.MapGet("/products", (IProductService service) => service.GetAll()); // no unused logger dependency
app.MapPost("/products", (IProductService service, ILogger<Program> logger, ProductDto dto) => { ... });
Real-world example A minimal API's simple GET endpoint declares only IProductService as a dependency, while its more complex POST endpoint additionally declares ILogger, avoiding the controller pattern's tendency to inject dependencies into a shared constructor that not every action actually uses.
Common follow-ups: Does this parameter-based injection have any measurable performance difference from constructor injection?;How does this affect testability compared to controller-based DI?
Dependency Injection;Testing ASP.NET Core Applications
Since both routing systems ultimately register into the same underlying ASP.NET Core endpoint routing system, policies configured centrally (via [Authorize]/RequireAuthorization, UseCors/RequireCors, and rate limiting policies) apply consistently whether attached via attributes (controllers) or fluent extension methods (minimal APIs) -- the underlying policy definitions (in AddAuthorization, AddCors, AddRateLimiter) are shared and referenced by name from either style.
builder.Services.AddAuthorization(options => options.AddPolicy("AdminOnly", p => p.RequireRole("Admin")));
// Controller usage
[Authorize(Policy = "AdminOnly")]
public class AdminController : ControllerBase { }
// Minimal API usage of the SAME policy
app.MapDelete("/admin/users/{id}", DeleteUser).RequireAuthorization("AdminOnly");
Real-world example A hybrid application defines its 'AdminOnly' authorization policy once, applying it consistently to both a legacy AdminController (via attribute) and new minimal API admin endpoints (via RequireAuthorization), avoiding policy definition duplication across the two styles.
Common follow-ups: Are there any policies that behave subtly differently between the two invocation styles?;How would you audit a hybrid codebase to ensure policy consistency across both styles?
Authorization;Rate Limiting