ASP.NET Core Explained: Minimal APIs vs Controllers

Choosing between Minimal APIs and Controllers shapes your project structure, testability, and performance profile. Both run on the same Kestrel server and middleware pipeline. They differ in how you declare endpoints, bind parameters, validate input, and organize code as the app grows. This guide compares both with working .NET code, benchmarks-driven trade-offs, and refactoring examples.

Quick Takeaways

  • Minimal APIs declare endpoints with MapGet(), MapPost(), and friends. They carry less ceremony and a lighter request path.
  • Controllers use [ApiController], attribute routing, and filters. They provide a mature, convention-rich model for large teams.
  • Both share routing, DI, authentication, and middleware. Neither is “legacy.”
  • Use Minimal APIs for microservices and small-to-medium services. Use Controllers when you need MVC-style conventions, heavy model binding customization, or an existing controller codebase.
Criteria Minimal APIs Controllers
Boilerplate Low Medium
Startup/Request overhead Lower Slightly higher
Built-in validation Via filters or libraries [ApiController] automatic 400s
Filters Endpoint filters Action/Resource/Exception filters
Organization at scale Needs route groups/extensions Natural class-per-resource
Learning curve Gentle Moderate
Native AOT support Strong Limited

What Are Minimal APIs and Controllers?

Minimal APIs

Minimal APIs map HTTP verbs and routes directly to delegates. There is no controller class, no base class, and no attribute routing required.

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductService, ProductService>();

var app = builder.Build();

app.MapGet("/products/{id:int}", async (int id, IProductService service) =>
{
    var product = await service.GetByIdAsync(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

app.Run();

The route parameter id binds from the path. IProductService resolves from dependency injection automatically. A missing product returns 404; a found one returns 200 with JSON.

Controllers

Controllers group related actions in a class derived from ControllerBase. Attributes define routes, verbs, and behavior.

// Controllers/ProductsController.cs
[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service) => _service = service;

    [HttpGet("{id:int}")]
    public async Task<ActionResult<Product>> GetById(int id)
    {
        var product = await _service.GetByIdAsync(id);
        return product is null ? NotFound() : Ok(product);
    }
}

Setup requires builder.Services.AddControllers() and app.MapControllers(). Behavior matches the Minimal API above: same route, same status codes.

Syntax Breakdown: Side-by-Side

Parameter Binding

Minimal APIs infer binding sources from the parameter type and name. Controllers use the same engine but with [ApiController] inference rules.

// Minimal API: explicit binding attributes
app.MapPost("/orders", (
    [FromBody] CreateOrderRequest body,
    [FromHeader(Name = "X-Correlation-Id")] string correlationId,
    [FromQuery] bool dryRun,
    ILogger<Program> logger) =>
{
    logger.LogInformation("Order request {CorrelationId}, dryRun={DryRun}", correlationId, dryRun);
    return Results.Created($"/orders/{Guid.NewGuid()}", body);
});

Complex types default to body, simple types to route/query, and registered services to DI. Explicit attributes remove ambiguity.

// Controller: same binding
[HttpPost("orders")]
public IActionResult Create(
    [FromBody] CreateOrderRequest body,
    [FromHeader(Name = "X-Correlation-Id")] string correlationId,
    [FromQuery] bool dryRun)
{
    _logger.LogInformation("Order request {CorrelationId}, dryRun={DryRun}", correlationId, dryRun);
    return Created($"/orders/{Guid.NewGuid()}", body);
}

Return Types

Approach Minimal APIs Controllers
Typed results TypedResults.Ok(x), Results<Ok<T>, NotFound> ActionResult<T>
Implicit serialization Return an object, get 200 JSON Return an object from ActionResult<T>
OpenAPI metadata .Produces<T>(), .WithOpenApi() [ProducesResponseType]

TypedResults give compile-time safety and automatic OpenAPI metadata:

app.MapGet("/products/{id:int}", async Task<Results<Ok<Product>, NotFound>> (
    int id, IProductService service) =>
{
    var product = await service.GetByIdAsync(id);
    return product is null ? TypedResults.NotFound() : TypedResults.Ok(product);
});

The union return type tells tooling exactly which responses this endpoint can produce. No extra attributes needed.

Validation: Where the Two Models Differ Most

Controllers with [ApiController] validate DataAnnotations automatically and return a ProblemDetails 400 response. Minimal APIs historically required manual validation. .NET 10 added built-in validation support for Minimal APIs via AddValidation().

public record CreateOrderRequest(
    [Required, StringLength(50)] string CustomerName,
    [Range(1, 1000)] int Quantity);
// Minimal API (.NET 10+): opt in to built-in validation
builder.Services.AddValidation();

app.MapPost("/orders", (CreateOrderRequest request) =>
    TypedResults.Ok(request));

An invalid payload now returns 400 ProblemDetails without handwritten checks. For richer rules, use FluentValidation with an endpoint filter:

// Reusable endpoint filter using FluentValidation
public class ValidationFilter<T> : IEndpointFilter where T : class
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
        var model = context.Arguments.OfType<T>().FirstOrDefault();

        if (validator is not null && model is not null)
        {
            var result = await validator.ValidateAsync(model);
            if (!result.IsValid)
                return Results.ValidationProblem(result.ToDictionary());
        }

        return await next(context);
    }
}

app.MapPost("/orders", (CreateOrderRequest r) => TypedResults.Ok(r))
   .AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();

The filter runs before the handler, short-circuits with 400 on failure, and is reusable across endpoints.

Filters and Cross-Cutting Concerns

Concern Minimal APIs Controllers
Authorization .RequireAuthorization() [Authorize]
Pre/post logic Endpoint filters Action filters
Exception handling UseExceptionHandler() / IExceptionHandler Exception filters + middleware
Caching .CacheOutput() [ResponseCache], [OutputCache]
Rate limiting .RequireRateLimiting("policy") [EnableRateLimiting("policy")]

Controllers offer more filter stages (Authorization, Resource, Action, Exception, Result). Minimal APIs offer a single, simpler endpoint filter pipeline plus standard middleware. For most services, middleware plus endpoint filters cover everything.

Organizing Minimal APIs at Scale

The common criticism of Minimal APIs: Program.cs becomes a 2,000-line file. Solve it with route groups and extension methods.

// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder routes)
    {
        var group = routes.MapGroup("/products")
                          .WithTags("Products")
                          .RequireAuthorization();

        group.MapGet("/", GetAll);
        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create);

        return group;
    }

    private static async Task<Ok<List<Product>>> GetAll(IProductService service) =>
        TypedResults.Ok(await service.GetAllAsync());

    private static async Task<Results<Ok<Product>, NotFound>> GetById(
        int id, IProductService service)
    {
        var product = await service.GetByIdAsync(id);
        return product is null ? TypedResults.NotFound() : TypedResults.Ok(product);
    }

    private static async Task<Created<Product>> Create(
        Product product, IProductService service)
    {
        var created = await service.CreateAsync(product);
        return TypedResults.Created($"/products/{created.Id}", created);
    }
}
// Program.cs stays small
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapCustomerEndpoints();

Static handler methods are easy to unit test and keep Program.cs readable. A group applies shared authorization, tags, and filters once.

Performance Comparison

Minimal APIs skip the MVC action invoker, model-binding infrastructure for controllers, and filter pipeline stages. That produces measurable gains in microbenchmarks, mostly in allocations and per-request overhead.

Metric Minimal APIs Controllers
Per-request overhead Lower Higher (action invocation, filters)
Memory allocations Lower Higher
Startup time Faster Slower (controller discovery)
Native AOT Supported Not supported
Real-world impact Noticeable at very high throughput Negligible when I/O-bound

Treat these as directional. Database calls, serialization, and network latency usually dominate. Measure your own workload with BenchmarkDotNet or load tools like k6 before choosing on performance alone.

// Native AOT-friendly Minimal API with source-generated JSON
var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));

var app = builder.Build();
app.MapGet("/ping", () => new PingResponse("ok"));
app.Run();

public record PingResponse(string Status);

[JsonSerializable(typeof(PingResponse))]
internal partial class AppJsonContext : JsonSerializerContext { }

CreateSlimBuilder plus a JsonSerializerContext removes reflection-based serialization, which Native AOT requires. The result is a small binary with fast cold starts, ideal for containers and serverless.

Testing Both Approaches

Use WebApplicationFactory<T> for integration tests. It works identically for both models.

public class ProductApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductApiTests(WebApplicationFactory<Program> factory) =>
        _client = factory.CreateClient();

    [Fact]
    public async Task GetById_UnknownId_Returns404()
    {
        var response = await _client.GetAsync("/products/999999");
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}

Add public partial class Program { } to expose the entry point when using top-level statements. The test hits the real pipeline and passes regardless of which model serves the route.

Best Practices: Bad Code vs. Good Code

Anti-Pattern 1: Everything in Program.cs

// BAD: business logic, data access, and routing mixed together
app.MapPost("/orders", async (CreateOrderRequest req, AppDbContext db) =>
{
    if (string.IsNullOrWhiteSpace(req.CustomerName)) return Results.BadRequest();
    var order = new Order { CustomerName = req.CustomerName, Quantity = req.Quantity };
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    await new SmtpClient().SendMailAsync("a@b.com", req.CustomerName, "Ordered", "...");
    return Results.Ok(order);
});

This handler validates, persists, and sends email. It is untestable and tightly coupled.

// GOOD: thin endpoint, logic in a service
app.MapPost("/orders", async Task<Results<Created<OrderDto>, ValidationProblem>> (
    CreateOrderRequest req, IOrderService orders, CancellationToken ct) =>
{
    var order = await orders.CreateAsync(req, ct);
    return TypedResults.Created($"/orders/{order.Id}", order);
})
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();

The endpoint only maps HTTP to a service call. Validation lives in a filter, logic in IOrderService, and a CancellationToken flows through.

Anti-Pattern 2: Fat Controllers

// BAD: controller owns data access and business rules
[HttpPost]
public async Task<IActionResult> Create(OrderDto dto)
{
    var db = new AppDbContext();                // manual instantiation
    var total = dto.Items.Sum(i => i.Price * i.Qty) * 1.2m;  // magic number
    db.Orders.Add(new Order { Total = total });
    db.SaveChanges();                           // sync I/O blocks the thread
    return Ok();
}
// GOOD: injected service, async, typed result
[HttpPost]
public async Task<ActionResult<OrderDto>> Create(
    CreateOrderRequest request, CancellationToken ct)
{
    var order = await _orders.CreateAsync(request, ct);
    return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
}

The refactor injects dependencies, uses async/await, removes the magic tax rate from the controller, and returns a proper 201 with a Location header.

Decision Guide: Which Should You Choose?

Scenario Recommendation
New microservice or small API Minimal APIs
Native AOT or serverless cold-start targets Minimal APIs
Large existing MVC/Web API codebase Controllers (migrate gradually if desired)
Team accustomed to MVC conventions Controllers
Heavy use of custom model binders, IModelBinder, or complex filter stages Controllers
Vertical slice or feature-folder architecture Minimal APIs with route groups
Mixed needs Both in one app

You can mix both in a single project. Call AddControllers() and MapControllers() alongside MapGet() calls. Migrate endpoint by endpoint rather than rewriting.

Common Edge Cases

Ambiguous binding. A complex parameter without [FromBody] may bind unexpectedly on endpoints with multiple complex types. Annotate explicitly.

Form binding. Minimal APIs support [FromForm] and IFormFile. Antiforgery validation is required for form endpoints, so register AddAntiforgery() and call UseAntiforgery().

Versioning. Use Asp.Versioning.Http for Minimal APIs and Asp.Versioning.Mvc for controllers. Both integrate with route groups or attributes.

OpenAPI. Both models work with the built-in Microsoft.AspNetCore.OpenApi package via AddOpenApi() and MapOpenApi(). Typed results reduce the metadata you write by hand.

FAQ

Are Minimal APIs faster than Controllers in ASP.NET Core?

Yes, usually by a small margin. Minimal APIs skip parts of the MVC pipeline, which cuts allocations and per-request overhead. In I/O-heavy apps, database and network latency dominate, so the difference is rarely visible to users.

Can I use Minimal APIs and Controllers in the same project?

Yes. Register controllers with AddControllers() and MapControllers(), then add endpoints with MapGet() and MapPost(). They share middleware, DI, authentication, and routing, so you can migrate incrementally.

Are Minimal APIs production-ready for large applications?

Yes. Use route groups, static endpoint classes, endpoint filters, and services to keep code organized. Many production systems run Minimal APIs at scale. The main requirement is discipline in structure, since the framework does not impose it.

Do Minimal APIs support validation like Controllers?

Yes. Controllers with [ApiController] validate DataAnnotations automatically. Minimal APIs support built-in validation through AddValidation() in recent .NET versions, or through FluentValidation with a reusable endpoint filter.

Hot this week

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Topics

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Robot Computers in 2026: Raspberry Pi 5, Jetson, and Industrial PCs Compared

Raspberry Pi 5 vs NVIDIA Jetson vs industrial PCs for ROS 2 robots. See specs, latency math and a decision matrix, then pick your compute.

AI in Robotics: Programmed Behavior vs Machine Learning (and When You Need Each)

Programmed control or machine learning? Compare PID, FSMs, and RL with ROS2 code, benchmarks, and a decision framework. Pick the right one.

What Is ROS 2? A Beginner’s Guide to the Robot Operating System in 2026

Learn what ROS 2 is, how nodes, topics, and DDS work, and build your first publisher and subscriber. Start your ROS 2 project today.

Related Articles

Popular Categories