CoderBlog
ASP.NET

Minimal APIs in .NET 9: The HTTP Framework You Already Had

A working engineer's guide to Minimal APIs in .NET 9 — when the endpoint-as-a-function model beats controllers, when it does not, and the small set of conventions that make a Minimal API codebase feel like one.

Every ASP.NET developer has, at some point, written a ValuesController that returns IEnumerable<string>. The scaffolding for that controller — the class, the inheritance, the [Route], the [HttpGet], the method signature, the return type — is at minimum twelve lines of code for a one-line behavior. The MVC pattern is the right shape for applications that have the kind of complexity MVC was designed to absorb: multi-page forms, server-rendered views, action filters, model binders, view engines. Most HTTP APIs are not that. Most HTTP APIs are a JSON-in, JSON-out function in a routing table. For those, MVC is a tax you pay for shape you do not need.

Minimal APIs, which shipped in .NET 6 and became a serious option in .NET 7 and .NET 8, are the framework's response to that observation. In .NET 9, with the addition of compiled route metadata, typed results, and the Microsoft.AspNetCore.OpenApi package moving out of preview, Minimal APIs are the default choice for new HTTP services on the Microsoft stack. Not the only choice. The default. This is what that actually looks like in production, what the framework gets right, and what it still gets wrong.

Cover image: minimal APIs in .NET 9

Fig. 01 — A minimal API as a single line of code. The endpoint is a delegate. The route is a string. The rest is convention.

The Endpoint as a Function

The whole pitch for Minimal APIs is that an HTTP endpoint is a function with routing metadata. The function takes a request, returns a response. The routing metadata tells the framework how to find the function. The function does the work. There is no controller, no action, no model binder, no action filter, no view engine. The minimum viable HTTP service in .NET 9 is twelve lines:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();

app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));

app.Run();

That is a complete HTTP service. It listens on the Kestrel default port, exposes one endpoint, returns JSON, and produces an OpenAPI document. There is no Startup.cs. There is no Program.cs that contains a Main method. There is no appsettings.json you need to touch. The 12 lines compile, run, and survive being put behind a load balancer.

The thing that took me a while to internalize is that the reduction is not aesthetic. It is structural. A controller-based API has, on average, four layers of indirection between the URL and the code: the route, the controller class, the action method, the action result. Each layer is an opportunity for configuration and an opportunity for confusion. A Minimal API has one: the delegate. The route is a string, the handler is a function. The framework does the rest.

When I switched a 30-endpoint internal service from controllers to Minimal APIs in 2024, the codebase went from 1,400 lines to 480. The new version was easier to read, easier to test, and easier to deploy. Nothing about the HTTP behavior changed. The only thing that changed was the shape of the code on the page.

What the Framework Got Right in .NET 9

The Minimal APIs of 2022 were a sketch. The Minimal APIs of 2026 are a finished product. The three improvements that changed how I write code.

Compiled route metadata. In .NET 6 and 7, every request triggered a reflection-based scan of the route table. With 50 endpoints this was fine. With 500 endpoints on a hot path it was not. .NET 9 moves the route table to a compiled AOT-friendly structure. The cost of looking up a route is now a hash lookup, not a reflection walk. For services that have grown past 200 endpoints, the difference is the difference between a request budget you can fit in your head and a request budget you cannot.

Typed results. The original Results.Ok(value) returned an IResult that the framework then translated to an HTTP response. The translation was magic — sometimes good magic, sometimes not. The new typed results are explicit. You can see what the framework will do. The Results<TValue, TProblem> overload gives you two return types and the framework picks the right one based on what you actually return. For services with consistent error responses, this is the end of IActionResult-shaped boilerplate.

Microsoft.AspNetCore.OpenApi moved out of preview. The OpenAPI integration is now stable, ships with the framework, and supports the same source generators that power the new System.Text.Json AOT pipeline. The result is an OpenAPI document that is correct by construction, generated at build time, and includes all the metadata you need to generate clients, run contract tests, and validate with a tool like Spectral. In 2022 this was a third-party library. In 2026 it is a one-liner.

builder.Services.AddOpenApi();

app.MapPost("/orders", async (OrderRequest req, AppDbContext db) =>
{
    var order = Order.FromRequest(req);
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    return TypedResults.Created($"/orders/{order.Id}", order);
})
.WithOpenApi();

The endpoint is a function. The metadata is a fluent chain. The OpenAPI document is correct because it is generated from the same source as the code.

What Minimal APIs Are NOT Good At

The endpoint-as-a-function model is a good fit for 80% of the HTTP endpoints in a real codebase. It is a bad fit for the other 20%, and trying to use it there produces code that is worse than the controller version. The places I have learned to reach for a controller instead.

Multi-action controllers with shared pre/post logic. The MVC pattern shines when every action in a controller wants the same auth check, the same rate limit, the same audit log, the same exception filter, the same telemetry. In Minimal APIs you can express that with endpoint filters, and the syntax is fine, but the moment you have more than three of them on a route group, the chain is harder to read than the controller attribute. Reach for a controller.

// In Minimal APIs, the chain is fine — but it grows linearly per group.
var group = app.MapGroup("/admin")
    .RequireAuthorization("AdminPolicy")
    .AddEndpointFilter<RateLimitFilter>()
    .AddEndpointFilter<AuditLogFilter>()
    .AddEndpointFilter<TelemetryFilter>();

group.MapPost("/users", ...);
group.MapDelete("/users/{id}", ...);
// By the fourth filter, I miss the [Authorize] attribute on a class.

Strongly-typed view models with shared validation. MVC's model binding + validation pipeline is a real piece of infrastructure. A Minimal API can do model binding, but validation is the responsibility of the developer, and the patterns for sharing validation rules are worse than [Required] and [Range]. If your endpoint accepts a complex validated object, the MVC pipeline is doing real work that the Minimal API pipeline is not.

Web applications with server-rendered views. This is the obvious one. Minimal APIs do not render Razor. They can serve a static file. If you need a server-rendered HTML page, MVC is still the right answer. Razor Pages for page-shaped content, Blazor for component-shaped content, Minimal APIs for JSON-shaped content. The framework is finally honest about which shape is which.

Anything where the routing logic is dynamic. MVC's [Route("[action]")] and [Route("{controller=Home}/{action=Index}")] are powerful because they let the framework derive routes from convention. Minimal APIs require you to write the route string. For services with a handful of endpoints, this is fine. For services with route tables that change at runtime (think: a multi-tenant admin tool where each tenant has its own routes), the convention-based approach wins.

The Conventions That Make a Minimal API Codebase Feel Like One

The framework gives you the primitives. The conventions are what make a 50-endpoint service readable instead of a wall of delegates. The five that have stuck for me.

One file per route group. Not "one file per endpoint" — that is overkill. One file per route group, with all the endpoints in the group, the shared filters, the shared dependencies. Program.cs becomes a list of app.MapXxxGroup() calls.

Endpoint filters for cross-cutting concerns, attribute-style where possible. When the filter is the same for every endpoint in a group, put it on the group. When it is the same for every endpoint in the service, put it in a base endpoint convention. When it varies per endpoint, use the fluent chain on the individual route.

Typed request and response DTOs everywhere. record types for requests, record types for responses, record types for query parameters. The JSON serialization is identical to the record. The OpenAPI generator produces a correct schema. There is no class to maintain.

Results for the simple cases, TypedResults for the typed cases. A 200 with a body is Results.Ok(value). A 201 with a Location header is TypedResults.Created($"/x/{id}", value). A 422 with a problem details body is TypedResults.Problem(...). The framework picks the right wire format.

WebApplicationFactory<TEntryPoint> for integration tests. The Minimal API surface maps directly to the test surface. You can stand up the entire service in-memory, send real HTTP requests against it, and assert on the real responses. No mocking the controller, no mocking the action filter. The test is the contract.

When to Choose Controllers

The honest list, with the thinking that goes into each.

  • You are inheriting a controller codebase. A 200-controller service is not a rewrite candidate. The refactor is real work and the benefit is not. Leave the controllers.
  • You are building a multi-tenant CMS or admin tool with dynamic routes. The convention-based routing of MVC is the right tool.
  • You need shared validation across a large DTO surface. [Required], [Range], [StringLength] on a class is a lot less code than FluentValidation rules in a IValidator<T> chain.
  • You have a team that knows MVC and does not know Minimal APIs. The framework is not the bottleneck. The team's familiarity is. A working service in a familiar shape beats an elegant service in an unfamiliar shape.

The Shape of What Comes Next

The HTTP framework story on the Microsoft stack in 2026 is the cleanest it has ever been. Minimal APIs for JSON-in, JSON-out services. Blazor for interactive web UIs. Razor Pages for page-shaped content. SignalR for real-time. gRPC for inter-service. Each tool for one job, no overlap. The era of "ASP.NET MVC for everything" is over, and the era of "the right framework for the right shape" is here. The right shape for an HTTP API is, in 2026, almost always Minimal APIs.

A small toolkit to get started, if you have not already: install the .NET 9 SDK, then dotnet new web. Add Microsoft.AspNetCore.OpenApi for the OpenAPI document. Add Swashbuckle.AspNetCore if you want the Swagger UI (it still works alongside the new OpenAPI package, just slower). Run dotnet run. You will have a working HTTP service in under a minute. Spend a Saturday migrating one of your existing controllers to a Minimal API. Watch how much code disappears. Watch how much clarity appears. That is the renaissance.

The endpoint is a function. The route is a string. Everything else is convention.

Five Things That Will Bite You on the Way

Before you commit, here are the five production snags I have hit, with the fix that worked for us.

1. Endpoint filters compose, but the order is non-obvious. Endpoint filters run in the order they are added to the chain. If you have RequireAuthorization().AddEndpointFilter<RateLimitFilter>().AddEndpointFilter<AuditLogFilter>(), the rate limit fires before the auth check. For most services that is wrong — you want to fail fast on auth, not on rate limit. Read the docs on filter ordering, then test the order in production.

2. OpenAPI document generation is a build-time thing now, not a runtime thing. The new Microsoft.AspNetCore.OpenApi source generator runs at build time. If you change an endpoint signature, the OpenAPI document changes too — but only at the next build. If you forget to rebuild before deploying, the production OpenAPI document will be one step behind. Wire dotnet build into your CI before the OpenAPI document is consumed.

3. Bind complex query strings with AsParameters, not [FromQuery] on each property. A record QueryParams(int Page, int PageSize, string? Filter) annotated with [AsParameters] gives you one bound argument, one validation point, and one place to set defaults. The hand-written [FromQuery] int page = 1, [FromQuery] int pageSize = 20 is three lines per parameter and gets out of sync with the OpenAPI document in weeks.

4. Results.Ok is fast, but TypedResults.Ok is faster. The difference is allocation. Results.Ok(value) boxes the value into IResult. TypedResults.Ok(value) returns a concrete Ok<T> that the framework can serialize without reflection. For a hot path that is called millions of times per day, the typed result is the difference between a request budget you can fit in your head and one you cannot.

5. Integration tests are easier than unit tests, and you should write them instead. A Minimal API endpoint is a function. A unit test of a function mocks the dependencies. An integration test of an endpoint stands up the whole service, sends a real HTTP request, and asserts on the real response. The integration test is more code, but it is testing the actual contract, not the contract you think you have. The framework's WebApplicationFactory<TEntryPoint> makes this trivial. Skip the unit test. Write the integration test.

Winson Yau

Engineer, writer, and founder of CoderBlog. Building tools and writing about the craft of software from Hong Kong.

Comments

Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.