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.
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.
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.
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.
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.
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.
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) => ...);
{id:int} is a route constraint — this segment must parse as an integer to match.app.MapGet("/orders/{id:int}", (int id, IOrderService orderService) =>
orderService.GetById(id));
orderService is 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>>();
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));
orders automatically gets the /api/orders prefix, and inherits anything configured on the group — like RequireAuthorization() — without repeating it on every single route.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.
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.
MapGet, ASP.NET Core examines your delegate's parameter list — types, names, and any explicit [From...] attributes — and builds a specialized RequestDelegate tailored to exactly that signature.AddEndpointFilter composes 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.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.
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.
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.
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.
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.
Lesson 257 makes this comparison in full, once you've formally seen both sides.
AddEndpointFilter = the Minimal API answer to MVC filters.MapGroup = a shared prefix and shared configuration for a whole family of routes.
MapGet/MapPost/MapPut/MapDelete register a route + HTTP method combination directly against a delegate — no controller class involved.IEndpointFilter is the Minimal API mechanism for cross-cutting, per-endpoint logic — the modern equivalent of classic MVC filters for this style.MapGroup shares a route prefix and configuration (filters, authorization) across a whole family of related routes.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?
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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.