Minimal APIs
By the end of this lesson
Define endpoints without controllers, and choose between the two approaches.
A minimal API defines an endpoint by mapping a route to a function. There is no controller class, no base type and no attribute on a method: MapGet takes a template and a lambda, and that is the endpoint.
Everything underneath is the same framework. Same routing, same middleware pipeline, same dependency injection, same model binding, same results. What changes is how much structure you write around the handler, and where shared behaviour can live.
var app = builder.Build();
var employees = app.MapGroup("/api/employees")
.WithTags("Employees")
.RequireAuthorization();
employees.MapGet("/{id:int}", async (int id, EmployeeService service, CancellationToken ct) =>
{
var employee = await service.FindAsync(id, ct);
return employee is null
? Results.NotFound()
: Results.Ok(EmployeeResponse.From(employee));
});
employees.MapPost("/", async (
CreateEmployeeRequest request,
EmployeeService service,
CancellationToken ct) =>
{
var created = await service.CreateAsync(request, ct);
return TypedResults.Created($"/api/employees/{created.Id}", EmployeeResponse.From(created));
});
app.Run();- MapGroup gives a set of endpoints a shared prefix and shared metadata. RequireAuthorization applied to the group covers every endpoint in it, including ones added next year by somebody who did not read this file.
- The lambda's parameters are resolved exactly as a controller action's are. id comes from the route because the names match, and EmployeeService comes from dependency injection because it is a registered service.
- Results.NotFound and Results.Ok produce the same responses as the controller helpers of the same name. Nothing about the HTTP surface changes between the two styles.
- TypedResults is the same set of results with a specific return type instead of a general one. That makes a handler easier to unit test, because the test can assert on the type rather than casting, and it gives generated API documentation better information.
- The first handler returns two different result types, so its inferred return type is the general IResult. If you want a declared type, write the handler as a named static method and give it a results union return type listing the outcomes.
- There is no constructor anywhere, so every dependency arrives as a parameter. That keeps each handler's needs visible where it is written, and it means two endpoints in the same group can depend on completely different things.
The pieces you build a minimal API from:
- MapGet, MapPost, MapPut, MapDelete, MapPatch
- One call per method and template. MapMethods covers anything outside that set.
- MapGroup
- A shared prefix plus shared metadata, filters and requirements for a set of endpoints. Groups can be nested.
- Results and TypedResults
- Factory methods for responses. TypedResults returns a concrete type, so prefer it unless you need the general one.
- AddEndpointFilter
- Cross-cutting behaviour around a handler or a whole group: the minimal API answer to an action filter. It sees the arguments and can replace the result.
- WithName, WithTags, WithSummary
- Metadata used for link generation and generated documentation. WithName also gives you a target for building URLs.
The two styles differ in where things live rather than in what they can do. Both are current, both are supported, and the runtime treats their endpoints the same way.
| Minimal APIs | Controllers | |
|---|---|---|
| Where an endpoint lives | In a Map call, in Program.cs or a small static class | As a method on a class, discovered by convention |
| How dependencies arrive | As parameters on each handler | Through the constructor, shared by every action in the class |
| Cross-cutting behaviour | Endpoint filters, applied per endpoint or per group | Action filters and conventions, applied by attribute or globally |
| Validation of data annotations | Not automatic. You check explicitly, or add a filter that does. | The ApiController attribute returns a 400 before your code runs |
| Suits | A small surface, or endpoints that genuinely differ from each other | A large surface sharing conventions, filters and response shapes |
| Effort to read for the first time | Low. One file can show the whole API. | Higher. You follow attributes and conventions to find behaviour. |
Summary
- A minimal API endpoint is a route template plus a function; routing, the pipeline, dependency injection and binding are unchanged
- MapGroup shares a prefix, metadata and requirements such as authorization across a set of endpoints
- TypedResults gives a concrete return type, which helps both tests and generated documentation
- Minimal APIs trade shared convention for less ceremony; controllers trade ceremony for a home for shared behaviour
- Automatic validation of data annotations comes with the ApiController attribute and is something you add yourself in a minimal API
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Move the endpoints out of Program.cs
Convert the two endpoints above into a static class with one extension method that maps the whole group, then call that method from Program.cs.
Keep the routes and status codes identical, and keep the authorization requirement on the group.
Show solution
The extension method pattern is what keeps minimal APIs readable past a handful of endpoints. Program.cs ends up listing features rather than endpoints, which is the same summary a controller-based project gets from its folder structure.
Putting the group requirements inside the extension method matters as much as moving the code. It keeps the prefix, the tags and the authorization requirement next to the endpoints they apply to, so somebody adding a third endpoint sees them.
Named static handlers are worth the extra lines once a handler grows past a few statements. A named method can be called directly from a unit test with fake dependencies, no HTTP involved.
public static class EmployeeEndpoints
{
public static RouteGroupBuilder MapEmployeeEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/employees")
.WithTags("Employees")
.RequireAuthorization();
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create);
return group;
}
private static async Task<IResult> GetById(int id, EmployeeService service, CancellationToken ct)
{
var employee = await service.FindAsync(id, ct);
return employee is null
? Results.NotFound()
: Results.Ok(EmployeeResponse.From(employee));
}
private static async Task<IResult> Create(
CreateEmployeeRequest request,
EmployeeService service,
CancellationToken ct)
{
var created = await service.CreateAsync(request, ct);
return TypedResults.Created($"/api/employees/{created.Id}", EmployeeResponse.From(created));
}
}
// Program.cs
app.MapEmployeeEndpoints();Think about it
Which style for this team?
A team is starting an API with around sixty endpoints across nine resources. Every list endpoint pages the same way, every error uses the same body shape, and three of the resources need the same audit behaviour.
Which style would you choose, and what would change your mind?
Show solution
Controllers are the easier recommendation here. The requirements are mostly about shared behaviour: one paging convention, one error shape, one audit concern across several resources. Filters and a base controller give all three in one place, and automatic validation across nine request types saves real work.
What would change the mind: a team already fluent in endpoint filters and groups can get the same consistency from minimal APIs, and a group per resource maps neatly onto nine resources. Sixty endpoints is not too many for minimal APIs; it is only too many for sixty unstructured Map calls.
The decision that matters more than the style is deciding once, writing it down, and not mixing the two inside the same resource. A codebase where half the employees endpoints are controllers and half are minimal is harder to work in than either choice made consistently.
Saved in this browser only.