ASP.NET Core Minimal APIs Tutorial: Routes as Functions
You already wrote these. Now let's formally learn what they actually are.
In lessons 161 and 162, you wrote things like app.MapGet("/tasks/{id}", (int id, ITaskRepository repo) => repo.GetById(id)). It worked. Route parameters got matched up, a repository got handed to you out of nowhere, and JSON came back to the caller — all without a single class, base type, or attribute in sight. You were using Minimal APIs, pragmatically, before this course had formally introduced the term.
This lesson makes that formal. You'll learn exactly what mechanism binds route parameters and services into your delegate's parameter list, how to group related routes cleanly, how to hook cross-cutting logic into specific endpoints with IEndpointFilter, and — just as importantly — an honest answer to when this style is the right call and when it starts to strain.
In this lesson, you'll learn MapGet/MapPost/MapPut/MapDelete, how dependency injection reaches directly into a handler's parameters with no constructor involved, endpoint filters, and grouping related routes with MapGroup.
What Is It?
The Simple Explanation
Minimal APIs are a way of building HTTP endpoints in ASP.NET Core by mapping a route directly to a function — no controller class, no base class, no attributes required. You write app.MapGet("/products/{id}", ...), and the function you pass in is the entire handler for that route.
The Technical Definition
A Minimal API endpoint is registered by calling one of the Map... extension methods on WebApplication (or on an IEndpointRouteBuilder more generally) — MapGet, MapPost, MapPut, MapDelete, and others for the remaining HTTP verbs. Each call takes a route template and a delegate. Internally, ASP.NET Core inspects the delegate's parameter list via a mechanism called the request delegate factory, and generates code that extracts each parameter's value from the appropriate part of the incoming request — the route, the query string, the body, or the DI container — before invoking your delegate.
Why Does It Exist?
The Problem — Controllers Carry Ceremony Small APIs Don't Need
Classic MVC Controllers (lesson 257) require a fair amount of structure before you write your first line of business logic: a class, a base type, attribute routing, a project convention for where controllers live and how they're discovered. For a large, sprawling API with dozens of resources, that structure earns its keep. But for a small service — a single-purpose microservice with five endpoints, or a quick internal tool — that ceremony is pure overhead, adding boilerplate without adding clarity.
The Solution — Routes as Functions
Minimal APIs, introduced in .NET 6, strip that ceremony away. A route and its handling logic sit right next to each other, as a route template and a function. There's still a full framework underneath — the same DI container, the same middleware pipeline, the same model binding concepts from lesson 258 — but the amount of code required to expose one HTTP endpoint drops to almost nothing.
Big Picture
app.MapPost("/orders/{customerId}", (int customerId, CreateOrderRequest body, IOrderService svc) => ...)
│ │ │ │
route template from ROUTE from REQUEST BODY from DI CONTAINER
(HTTP verb: POST) (matches "{customerId}") (complex type → JSON) (registered service)
Every piece of a Minimal API's signature is doing double duty: it's both the ordinary C# parameter list and the declaration of where each value should come from. There's no separate configuration step — the shape of the function is the binding contract.
How It Works
app.MapGet("/orders/{id:int}", (int id) => ...);
app.MapPost("/orders", (CreateOrderRequest req) => ...);
app.MapPut("/orders/{id:int}", (int id, UpdateOrderRequest req) => ...);
app.MapDelete("/orders/{id:int}", (int id) => ...);
- Each call registers one route + HTTP method combination as an endpoint.
{id:int}is a route constraint — this segment must parse as an integer to match.
- A parameter whose name matches a route template segment is bound from the route. A simple type not matched in the route typically comes from the query string. A complex, class-typed parameter is generally bound from the JSON request body. Full detail — and the important honest caveats — is lesson 258's entire subject.
app.MapGet("/orders/{id:int}", (int id, IOrderService orderService) =>
orderService.GetById(id));
orderServiceis never constructed by you — the parameter type is recognized as a registered DI service, and the container supplies it, exactly as it would for a constructor parameter, except there's no class or constructor at all here.
app.MapPost("/orders", (CreateOrderRequest req, IOrderService svc) => svc.Create(req))
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
- This is the Minimal API answer to MVC filters (lesson 255) — logic that wraps a specific endpoint's invocation, without needing a Controller class at all.
var orders = app.MapGroup("/api/orders").RequireAuthorization();
orders.MapGet("/{id:int}", (int id, IOrderService svc) => svc.GetById(id));
orders.MapPost("/", (CreateOrderRequest req, IOrderService svc) => svc.Create(req));
- Every route mapped on
ordersautomatically gets the/api/ordersprefix, and inherits anything configured on the group — likeRequireAuthorization()— without repeating it on every single route.
Simple Example
A small, complete CRUD surface for a Product record — using current C# idioms throughout:
public record Product(int Id, string Name, decimal Price);
public record CreateProductRequest(string Name, decimal Price);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductRepository, InMemoryProductRepository>();
var app = builder.Build();
var products = app.MapGroup("/api/products");
products.MapGet("/", (IProductRepository repo) => repo.GetAll());
products.MapGet("/{id:int}", (int id, IProductRepository repo) =>
repo.GetById(id) is { } product ? Results.Ok(product) : Results.NotFound());
products.MapPost("/", (CreateProductRequest request, IProductRepository repo) =>
{
var product = repo.Add(request.Name, request.Price);
return Results.Created($"/api/products/{product.Id}", product);
});
products.MapPut("/{id:int}", (int id, CreateProductRequest request, IProductRepository repo) =>
repo.Update(id, request.Name, request.Price) ? Results.NoContent() : Results.NotFound());
products.MapDelete("/{id:int}", (int id, IProductRepository repo) =>
repo.Delete(id) ? Results.NoContent() : Results.NotFound());
app.Run();
What's happening in each line: id binds from the route segment. request/body-shaped parameters bind from JSON. repo is resolved by DI, exactly like a constructor parameter would be — except there's no constructor, because there's no class. Results.Ok, Results.NotFound, Results.Created, and Results.NoContent are Minimal API helpers that produce the right status code and, where relevant, serialize the body as JSON.
Real-World Example
Revisit the employee management API from lesson 162, now formalized with a group and an endpoint filter for validation — the kind of shape a small, focused microservice settles into:
public class LoggingFilter(ILogger<LoggingFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
logger.LogInformation("Handling {Endpoint}", context.HttpContext.Request.Path);
var result = await next(context); // runs the rest of the filter chain, then the handler
logger.LogInformation("Finished with result type {Type}", result?.GetType().Name);
return result;
}
}
var employees = app.MapGroup("/api/employees")
.AddEndpointFilter<LoggingFilter>()
.WithTags("Employees");
employees.MapGet("/{id:int}", (int id, IEmployeeService service) => service.GetById(id));
employees.MapPost("/", (CreateEmployeeRequest request, IEmployeeService service) =>
Results.Created($"/api/employees", service.Create(request)));
Notice IEndpointFilter's shape — a next delegate you call to continue the chain, and code that can run both before and after that call. It's the same onion pattern from middleware (lesson 254) and from MVC filters (lesson 255), just scoped to one endpoint (or, here, one whole group of them) instead of the entire app or a whole Controller.
Under the Hood
- When you call
MapGet, ASP.NET Core examines your delegate's parameter list — types, names, and any explicit[From...]attributes — and builds a specializedRequestDelegatetailored to exactly that signature.
- From the perspective of the middleware pipeline (lesson 254) and endpoint routing (lesson 253), a Minimal API handler looks exactly like any other endpoint — it's the terminal node the pipeline calls into once routing has matched it.
AddEndpointFiltercomposes your filter around the handler in the same nested-delegate style as middleware — each filter you add wraps everything registered after it, all the way down to the actual handler invocation.
- Metadata attached to a group — a route prefix, filters, authorization requirements — is applied to every endpoint mapped through that group, without needing to repeat the configuration per route.
Common Confusion
"Minimal APIs are a lightweight, separate mini-framework"
They're not. They run on the exact same Kestrel server, the same middleware pipeline, and the same DI container as Controller-based apps (lesson 253). "Minimal" describes the amount of ceremony required to define a handler — not a smaller or different underlying framework. You can freely mix Minimal API endpoints and Controllers in the same app.
"Minimal APIs have no way to do cross-cutting logic per endpoint" — false
This was a fair criticism early on, before IEndpointFilter existed. It's not true anymore — endpoint filters cover much of the same ground classic MVC filters do (logging, validation, short-circuiting, transforming a result), just designed around delegates and groups instead of classes and attributes.
Common Mistakes
Mistake 1 — Cramming all the business logic straight into the lambda
A 60-line MapPost delegate that validates, calls three services, and builds a response — all inline. It compiles fine, but it's untestable in isolation and impossible to skim.
Keep the delegate thin — bind parameters, call a service method, translate the result to a Results.* response. Put the actual logic in an injected service, exactly as you would for a Controller action.
Mistake 2 — Assuming a complex parameter's binding source without checking
Adding a class-typed parameter to a Minimal API handler and assuming it behaves identically to how the same-shaped parameter would behave in a Controller action. The two styles have their own, separate binding-inference rules — lesson 258 covers this carefully.
When a signature is ambiguous, use an explicit [FromQuery]/[FromBody]/[FromServices] attribute rather than relying on inference you're not certain about.
Mistake 3 — Repeating the same prefix and filters on every route by hand
Writing app.MapGet("/api/orders/..."), app.MapPost("/api/orders/..."), and so on, each separately calling .RequireAuthorization() and .AddEndpointFilter<...>().
MapGroup("/api/orders") centralizes the prefix and any shared configuration in one place — change it once, and every route in the group picks it up.
When Should I Use It?
Minimal APIs fit well when
- The API is small to medium — a focused microservice, a handful of resources
- You want the leanest possible mental model — a route is a function, full stop
- You don't need classic MVC filters, and endpoint filters cover what you do need
- Your team values seeing the route and its handler side by side
Consider Controllers instead when
- The API is large, with many resources sharing significant cross-cutting logic best expressed as classic filters
- Your team already has established MVC conventions and tooling
- You want more built-in structure and scaffolding for a big, long-lived codebase
Lesson 257 makes this comparison in full, once you've formally seen both sides.
Mental Model
Remember:
· Route-matching name → from the route. Simple, unmatched type → usually the query string. Complex type → usually the body.
· A parameter typed as a registered service → resolved by DI, no constructor required.
·
AddEndpointFilter = the Minimal API answer to MVC filters.·
MapGroup = a shared prefix and shared configuration for a whole family of routes.
Key Takeaway
MapGet/MapPost/MapPut/MapDeleteregister a route + HTTP method combination directly against a delegate — no controller class involved.- Dependency injection reaches directly into a handler's parameter list — a service-typed parameter is resolved by the container exactly like a constructor parameter, with no constructor at all.
IEndpointFilteris the Minimal API mechanism for cross-cutting, per-endpoint logic — the modern equivalent of classic MVC filters for this style.MapGroupshares a route prefix and configuration (filters, authorization) across a whole family of related routes.- Minimal APIs and Controllers run on the identical underlying pipeline — the choice between them is about ceremony and structure, not capability.
Check Your Understanding
You've now formally learned the style you were already using in the lesson 161/162 project. Let's test whether the mechanics are clear.
1. In app.MapGet("/orders/{id:int}", (int id, IOrderService orderService) => ...), how does orderService get its value?
Show answer
Correct: B
Why B is correct: Minimal API handlers can declare a service-typed parameter directly, and the DI container supplies it at invocation time — the same resolution mechanism as constructor injection, just without a constructor because there's no class involved.
Why A is incorrect: Only id matches a route template segment; orderService has no corresponding route placeholder.
Why C is incorrect: That would defeat the entire point of DI, and Minimal APIs don't require it — the container handles construction.
Why D is incorrect: Query string binding applies to simple types like strings and numbers, not to registered service types.
Reinforcement: This is the exact behavior the lesson 161/162 project already relied on, now named explicitly.
2. What does app.MapGroup("/api/orders") primarily give you?
Show answer
Correct: B
Why B is correct: MapGroup produces a group you can map further routes onto; the prefix and any configuration (like .RequireAuthorization() or .AddEndpointFilter<T>()) chained onto the group apply to every route registered through it.
Why A is incorrect: Grouping is purely about routing and shared configuration — it has no effect on threading.
Why C is incorrect: OpenAPI documentation is a separate, unrelated concern from lesson 133 — grouping alone doesn't produce it.
Why D is incorrect: A route group is not a Controller equivalent — it has no relationship to [ApiController]'s specific behaviors (covered in lesson 257).
Reinforcement: Grouping removes repetition, not architecture — the underlying endpoints are still individual Minimal API delegates.
3. Which cross-cutting mechanism is designed specifically for Minimal API endpoints, playing a role similar to classic MVC action filters?
Show answer
Correct: B
Why B is correct: IEndpointFilter, applied via AddEndpointFilter, is the mechanism purpose-built for wrapping cross-cutting logic around Minimal API endpoint invocation.
Why A is incorrect: ActionFilterAttribute is part of the classic MVC filter system, which only applies to Controller actions, not Minimal API delegates.
Why C is incorrect: IHttpModule is a classic ASP.NET (System.Web) concept, not part of ASP.NET Core at all.
Why D is incorrect: ControllerBase is the base class for Controllers — irrelevant to the delegate-based Minimal API model.
Reinforcement: Same shape, different mechanism — IEndpointFilter is the Minimal-API-native equivalent of MVC filters.
4. A team is building a large, long-lived API with dozens of resources and heavy reliance on shared classic MVC filters across many actions. Based on this lesson's honest guidance, what's the reasonable recommendation?
Show answer
Correct: B
Why B is correct: The lesson is explicit that this is a genuine tradeoff, not a universal winner — a large API relying heavily on classic filters is exactly the scenario where Controllers' extra structure and filter ecosystem pay off.
Why A is incorrect: This directly contradicts the lesson's honest, balanced guidance — Minimal APIs are not presented as universally superior.
Why C is incorrect: Both Controllers and Minimal APIs are fully capable of building large APIs; this option invents a false limitation.
Why D is incorrect: DI works identically for Minimal APIs at any scale — that's not the deciding factor here.
Reinforcement: Choosing between the two styles is a real design decision based on team conventions, filter needs, and API size — not a fixed ranking.
You now have the formal vocabulary and mechanics behind the Minimal API code you already wrote back in lessons 161 and 162.