Minimal APIs were introduced in .NET 6 as a simpler alternative to controllers. Since then, they've matured significantly. With .NET 10, they've become a viable option for production applications. Let's explore the advanced patterns that make them truly practical.
Beyond the Basics
Most Minimal API examples show trivial endpoints:
app.MapGet("/hello", () => "Hello, World!");
That's fine for demos, but real applications need validation, error handling, authorization, and proper separation of concerns. Here's how to achieve that.
Route Groups for Organization
Instead of cluttering Program.cs with dozens of endpoint definitions, use route groups and extension methods:
// Program.cs
app.MapGroup("/api/books")
.MapBookEndpoints()
.RequireAuthorization();
// BookEndpoints.cs
public static class BookEndpoints
{
public static RouteGroupBuilder MapBookEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", GetAll);
group.MapGet("/{id:guid}", GetById);
group.MapPost("/", Create);
group.MapPut("/{id:guid}", Update);
group.MapDelete("/{id:guid}", Delete);
return group;
}
private static async Task<IResult> GetAll(
IBookService bookService,
CancellationToken ct)
{
var books = await bookService.GetAllAsync(ct);
return TypedResults.Ok(books);
}
private static async Task<IResult> GetById(
Guid id,
IBookService bookService,
CancellationToken ct)
{
var book = await bookService.GetByIdAsync(id, ct);
return book is not null
? TypedResults.Ok(book)
: TypedResults.NotFound();
}
}
This gives you the same organization as controllers without the ceremony of class inheritance and attribute routing.
Validation with Endpoint Filters
Minimal APIs support filters, which are analogous to action filters in MVC. Use them for validation:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var model = context.Arguments.OfType<T>().FirstOrDefault();
if (model is null)
return TypedResults.BadRequest("Request body is required");
var validator = context.HttpContext.RequestServices
.GetService<IValidator<T>>();
if (validator is not null)
{
var result = await validator.ValidateAsync(model);
if (!result.IsValid)
return TypedResults.ValidationProblem(result.ToDictionary());
}
return await next(context);
}
}
Apply it to specific endpoints:
group.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<CreateBookRequest>>();
TypedResults for Better OpenAPI
Using TypedResults instead of Results gives you proper OpenAPI documentation without additional attributes:
private static async Task<Results<Ok<BookDto>, NotFound, ValidationProblem>> GetById(
Guid id, IBookService bookService)
{
var book = await bookService.GetByIdAsync(id);
return book is not null
? TypedResults.Ok(book)
: TypedResults.NotFound();
}
The Results<T1, T2, T3> return type tells the OpenAPI generator exactly which status codes and response types this endpoint can produce.
Structured Error Handling
Wrap your endpoints with consistent error handling using a global exception handler:
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.ContentType = "application/problem+json";
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred",
Instance = context.Request.Path
};
context.Response.StatusCode = problem.Status.Value;
await context.Response.WriteAsJsonAsync(problem);
});
});
Dependency Injection in Parameters
One of the strengths of Minimal APIs is how naturally DI integrates with endpoint parameters. Services are injected directly as method parameters:
group.MapPost("/", async (
CreateBookRequest request,
IBookService bookService,
ILogger<BookEndpoints> logger,
ClaimsPrincipal user,
CancellationToken ct) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
logger.LogInformation("User {UserId} creating book", userId);
var book = await bookService.CreateAsync(request, userId!, ct);
return TypedResults.Created($"/api/books/{book.Id}", book);
});
No constructor injection, no field declarations. The DI container resolves parameters automatically based on their types.
When to Choose Minimal APIs
Minimal APIs shine for microservices, simple CRUD APIs, and projects where you want full control over the request pipeline. For large applications with dozens of related endpoints, controllers still offer better organization through inheritance and base controller patterns. The choice isn't binary though. You can mix controllers and Minimal APIs in the same project and use each where they fit best.
Comments (2)
I have a question: does this also apply to older versions?
Thanks for your comment — glad it helped!
I had the same experience, can confirm.
Thanks for sharing — very helpful.
Leave a Comment