Controllers or Minimal APIs

Decide between controllers and minimal APIs for a .NET 10 service, run both in one app safely, and avoid the split JSON settings trap.

Syntaxbuilder.Services.AddControllers(); app.MapControllers(); app.MapGroup("/api/products").MapGet("/{sku}", GetProduct); builder.Services.ConfigureHttpJsonOptions(o => ...); // minimal builder.Services.AddControllers().AddJsonOptions(o => ...); // MVC

Controllers and minimal APIs are two ways to declare endpoints on the same routing system. Both run behind the same middleware, use the same authentication and authorization, write the same problem details and feed the same OpenAPI document. What differs is how handlers are organised, which extension points exist and how much runtime machinery sits between the route and your code.

Side by side

ConcernControllersMinimal APIs
OrganisationClasses and attributes, conventions via IApplicationModelConventionLambdas or static methods, MapGroup per feature
Cross-cutting hooksAuthorization, resource, action, exception and result filtersEndpoint filters (one kind)
ValidationAutomatic with [ApiController]AddValidation() in .NET 10
Body formatsInput and output formatters, content negotiation, XML possibleJSON only
JSON Patch, ModelState, [FromForm] modelsMatureSupported, fewer helpers
Native AOT and trimmingNot supportedSupported, with the request delegate generator
Testing a handlerConstruct the controller, call the actionCall the static handler method

Performance, honestly

Minimal APIs do less work per request: no controller activation, no filter pipeline, no formatter selection. In benchmarks that serve a constant payload the difference is visible; behind a 2 ms database call it is noise. Pick minimal APIs for speed only when you need Native AOT, fast cold starts or a small memory footprint, such as scale-to-zero containers. Do not rewrite working controllers for throughput.

Mixing both in one app

AddControllers() plus MapControllers() and MapGroup(...) coexist without problems, which makes gradual migration possible: new features as groups, old ones left alone. Three things do not cross the boundary:

  • Filters. An MVC filter never runs for a minimal endpoint and an endpoint filter never runs for a controller. Put shared concerns in middleware, IExceptionHandler and authorization policies.
  • JSON options. ConfigureHttpJsonOptions configures minimal APIs; AddControllers().AddJsonOptions configures MVC. Set enum or naming rules in one and the two halves of your API serialise differently.
  • Route conflicts. The same template and verb in both is not caught at startup; the first matching request fails with an ambiguous match exception and a 500.

What teams decide

dotnet new webapi produces minimal APIs; --use-controllers produces controllers. For a new .NET 10 service, start with minimal APIs organised by feature, typed results and AddValidation(): the old gaps (validation, organisation, OpenAPI) are closed. Keep controllers in an existing codebase that relies on filters, conventions and content negotiation, and when the team already knows MVC well. Mixing within one service is fine during a migration; agree which style new code uses so it does not become two frameworks forever.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Add new reporting endpoints as minimal API groups to an existing controller-based orders service without touching the old controllers.
  • Build a scale-to-zero pricing service with Native AOT, which rules out controllers and points to minimal APIs.
  • Keep enum serialisation identical across /api/shipments (controller) and /api/v2/shipments (minimal) with one shared JSON setup.

More examples

The same endpoint written both ways

Both versions validate the same record and return 201 or 409. The minimal handler documents both statuses in OpenAPI from its <code>Results&lt;Created&lt;Product&gt;, Conflict&gt;</code> return type. <code>ActionResult&lt;Product&gt;</code> alone would document only a 200 with <code>Product</code>, so the controller needs the two <code>[ProducesResponseType]</code> attributes to say the same thing. The minimal handler is a static method with explicit dependencies, so a unit test calls it directly without constructing a controller.

Example · csharp
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddValidation();       // DataAnnotations for minimal APIs (.NET 10)
builder.Services.AddControllers();      // [ApiController] validates controllers
builder.Services.AddSingleton<ProductCatalog>();

var app = builder.Build();
app.MapControllers();
app.MapGroup("/api/v2/products").MapProductEndpoints();
app.Run();

public record CreateProduct([Required, StringLength(40)] string Sku, [Required] string Name, [Range(0.01, 10000)] decimal Price);
public record Product(string Sku, string Name, decimal Price);

public sealed class ProductCatalog
{
    private readonly ConcurrentDictionary<string, Product> _products = new(); // singleton: written by concurrent requests
    public bool TryAdd(Product product) => _products.TryAdd(product.Sku, product);
}

// Controller style
[ApiController]
[Route("api/products")]
public sealed class ProductsController(ProductCatalog catalog) : ControllerBase
{
    [HttpPost]
    [ProducesResponseType<Product>(StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status409Conflict)]
    public ActionResult<Product> Create(CreateProduct request)
    {
        var product = new Product(request.Sku, request.Name, request.Price);
        return catalog.TryAdd(product)
            ? Created($"/api/products/{product.Sku}", product)
            : Conflict();
    }
}

// Minimal API style
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProductEndpoints(this RouteGroupBuilder group)
    {
        group.MapPost("/", Create);
        return group;
    }

    private static Results<Created<Product>, Conflict> Create(CreateProduct request, ProductCatalog catalog)
    {
        var product = new Product(request.Sku, request.Name, request.Price);
        return catalog.TryAdd(product)
            ? TypedResults.Created($"/api/v2/products/{product.Sku}", product)
            : TypedResults.Conflict();
    }
}

One JSON policy for both halves

The two option types are different classes: <code>Microsoft.AspNetCore.Http.Json.JsonOptions</code> for minimal APIs and <code>Microsoft.AspNetCore.Mvc.JsonOptions</code> for controllers. One local function applied to both keeps them identical. A contract test that requests the same resource from a controller route and a minimal route and compares the JSON catches drift if someone later edits only one.

Example · csharp
using System.Text.Json;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);

static void ApplyApiJson(JsonSerializerOptions options)
{
    options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
    options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
}

// Minimal APIs, TypedResults and the problem details writer
builder.Services.ConfigureHttpJsonOptions(o => ApplyApiJson(o.SerializerOptions));

// MVC controllers: input and output formatters
builder.Services.AddControllers().AddJsonOptions(o => ApplyApiJson(o.JsonSerializerOptions));

var app = builder.Build();
app.MapControllers();
app.Run();

Starting points from the templates

The template choice only sets the starting shape; both styles can be added later with <code>AddControllers()</code> or <code>MapGroup</code>. The <code>webapiaot</code> template is the one to start from when Native AOT is a requirement, because controllers cannot be added to an AOT app at all, while a controller app can adopt minimal APIs at any time.

Example · bash
# Minimal APIs (the default for the webapi template)
dotnet new webapi -n Orders.Api

# Controllers instead of minimal APIs
dotnet new webapi -n Billing.Api --use-controllers

# Native AOT-ready minimal API (CreateSlimBuilder, JSON source generation)
dotnet new webapiaot -n Catalog.Api

# See every option the installed template offers
dotnet new webapi --help

Discussion

  • Be the first to comment on this lesson.