.NET Minimal APIs: Lightweight and Fast Endpoints
# .NET Minimal APIs: Lightweight and Fast Endpoints
Minimal APIs were introduced in .NET 6 as a radically simpler way to build HTTP endpoints in ASP.NET Core. Instead of controllers, attributes, and ceremonial class hierarchies, you define routes directly with lambda expressions. Yet beneath that simplicity lies full access to the ASP.NET Core platform: dependency injection, middleware, authentication, authorization, and OpenAPI tooling all work exactly as you'd expect.
In microservices I've built with Minimal APIs, startup time dropped noticeably and the cognitive overhead for new team members shrank dramatically. A developer can open `Program.cs`, see every route in the service, and start contributing within minutes.
Where Minimal APIs Shine
Getting Started: The Basics
A complete Minimal API can live in a single file:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductRepository, ProductRepository>();
var app = builder.Build();
app.MapGet(class="code-string">"/products", async (IProductRepository repo) =>
{
var products = await repo.GetAllAsync();
return Results.Ok(products);
});
app.MapGet(class="code-string">"/products/{id:int}", async (int id, IProductRepository repo) =>
{
var product = await repo.GetByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
});
app.MapPost(class="code-string">"/products", async (CreateProductRequest request, IProductRepository repo) =>
{
var product = await repo.CreateAsync(request);
return Results.Created($class="code-string">"/products/{product.Id}", product);
});
app.Run();Notice how dependency injection works through parameter binding. The framework inspects handler parameters and resolves services, route values, query strings, and body content automatically.
Parameter Binding in Depth
Minimal APIs use a convention-based binding system that's both powerful and intuitive:
class=class="code-string">"code-comment">// Route parameters
app.MapGet(class="code-string">"/users/{id:guid}", (Guid id) => ...);
class=class="code-string">"code-comment">// Query string parameters
app.MapGet(class="code-string">"/search", (string? query, int page = class="code-number">1, int pageSize = class="code-number">20) => ...);
class=class="code-string">"code-comment">// Header binding
app.MapGet(class="code-string">"/protected", ([FromHeader(Name = class="code-string">"X-Api-Key")] string apiKey) => ...);
class=class="code-string">"code-comment">// Body binding (automatic for complex types on POST/PUT)
app.MapPost(class="code-string">"/orders", (CreateOrderRequest request) => ...);
class=class="code-string">"code-comment">// Service injection
app.MapGet(class="code-string">"/stats", (IAnalyticsService analytics, ILogger<Program> logger) => ...);
class=class="code-string">"code-comment">// HttpContext access when you need full control
app.MapGet(class="code-string">"/custom", (HttpContext context) => ...);
class=class="code-string">"code-comment">// AsParameters for grouping multiple bindings
app.MapGet(class="code-string">"/products", async ([AsParameters] ProductFilterRequest filter) => ...);The `[AsParameters]` attribute is particularly useful for GET endpoints with many query parameters:
public record ProductFilterRequest(
string? Category,
decimal? MinPrice,
decimal? MaxPrice,
int Page = class="code-number">1,
int PageSize = class="code-number">20,
[FromServices] IProductRepository Repository = default!
);Typed Results for Clearer Contracts
One of the biggest improvements since .NET 7 is `TypedResults`, which makes your endpoint contracts explicit and feeds directly into OpenAPI documentation:
app.MapGet(class="code-string">"/orders/{id}", async Task<Results<Ok<Order>, NotFound>> (int id, IOrderService service) =>
{
var order = await service.GetByIdAsync(id);
return order is not null
? TypedResults.Ok(order)
: TypedResults.NotFound();
});
app.MapPost(class="code-string">"/orders", async Task<Results<Created<Order>, ValidationProblem>> (
CreateOrderRequest request, IOrderService service, IValidator<CreateOrderRequest> validator) =>
{
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
return TypedResults.ValidationProblem(validation.ToDictionary());
var order = await service.CreateAsync(request);
return TypedResults.Created($class="code-string">"/orders/{order.Id}", order);
});The `Results
Route Groups for Feature-Level Organization
Route groups let you share configuration across related endpoints, reducing repetition:
var api = app.MapGroup(class="code-string">"/api/v1");
var products = api.MapGroup(class="code-string">"/products")
.WithTags(class="code-string">"Products")
.RequireAuthorization(class="code-string">"ApiScope");
products.MapGet(class="code-string">"/", GetAllProducts);
products.MapGet(class="code-string">"/{id:int}", GetProductById);
products.MapPost(class="code-string">"/", CreateProduct).RequireAuthorization(class="code-string">"Admin");
products.MapPut(class="code-string">"/{id:int}", UpdateProduct).RequireAuthorization(class="code-string">"Admin");
products.MapDelete(class="code-string">"/{id:int}", DeleteProduct).RequireAuthorization(class="code-string">"Admin");
var orders = api.MapGroup(class="code-string">"/orders")
.WithTags(class="code-string">"Orders")
.RequireAuthorization();
orders.MapGet(class="code-string">"/", GetUserOrders);
orders.MapGet(class="code-string">"/{id:int}", GetOrderById);
orders.MapPost(class="code-string">"/", PlaceOrder);Groups support nested composition, shared filters, and authorization policies, making them the primary building block for organizing Minimal APIs beyond trivial examples.
Endpoint Filters: Cross-Cutting Logic
Endpoint filters are the Minimal API equivalent of action filters in MVC. They intercept requests before and after the handler runs:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var request = context.Arguments.OfType<T>().FirstOrDefault();
if (request is null)
return TypedResults.BadRequest(class="code-string">"Request body is required.");
var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
if (validator is not null)
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
return TypedResults.ValidationProblem(result.ToDictionary());
}
return await next(context);
}
}
class=class="code-string">"code-comment">// Usage
products.MapPost(class="code-string">"/", CreateProduct)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();You can also define inline filters for simpler scenarios:
app.MapGet(class="code-string">"/items/{id:int}", (int id) => ...)
.AddEndpointFilter(async (context, next) =>
{
var id = context.GetArgument<int>(class="code-number">0);
if (id <= class="code-number">0)
return TypedResults.BadRequest(class="code-string">"ID must be positive.");
return await next(context);
});OpenAPI Integration
Since .NET 9, Minimal APIs have first-class OpenAPI support built into the framework:
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
app.MapGet(class="code-string">"/products/{id}", async (int id, IProductRepository repo) =>
{
var product = await repo.GetByIdAsync(id);
return product is not null ? Results.Ok(product) : Results.NotFound();
})
.WithName(class="code-string">"GetProductById")
.WithDescription(class="code-string">"Retrieves a single product by its unique identifier.")
.Produces<Product>(class="code-number">200)
.Produces(class="code-number">404)
.WithTags(class="code-string">"Products");For earlier .NET versions, Swashbuckle or NSwag integrate seamlessly. The key is that `TypedResults` automatically generates accurate schemas, so you get reliable documentation without manual annotation.
Structuring Minimal APIs at Scale
The single-file approach works for small services, but anything with more than a handful of endpoints needs structure. The pattern I've found most effective is organizing endpoints into feature-based static classes:
class=class="code-string">"code-comment">// Features/Products/ProductEndpoints.cs
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup(class="code-string">"/products").WithTags(class="code-string">"Products");
group.MapGet(class="code-string">"/", GetAll);
group.MapGet(class="code-string">"/{id:int}", GetById);
group.MapPost(class="code-string">"/", Create).RequireAuthorization(class="code-string">"Admin");
group.MapPut(class="code-string">"/{id:int}", Update).RequireAuthorization(class="code-string">"Admin");
group.MapDelete(class="code-string">"/{id:int}", Delete).RequireAuthorization(class="code-string">"Admin");
return group;
}
private static async Task<Ok<List<ProductDto>>> GetAll(
IProductRepository repo, CancellationToken ct)
{
var products = await repo.GetAllAsync(ct);
return TypedResults.Ok(products);
}
private static async Task<Results<Ok<ProductDto>, NotFound>> GetById(
int id, IProductRepository repo, CancellationToken ct)
{
var product = await repo.GetByIdAsync(id, ct);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
}
class=class="code-string">"code-comment">// ... other handlers
}
class=class="code-string">"code-comment">// Program.cs stays clean
var app = builder.Build();
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapUserEndpoints();
app.Run();This approach gives you the clarity of seeing every route in one place per feature while keeping `Program.cs` as a composition root. Each feature file owns its routes, handlers, and authorization requirements.
Adding Auth, Validation, and Error Handling
Authentication and Authorization
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
.AddPolicy(class="code-string">"Admin", policy => policy.RequireRole(class="code-string">"admin"))
.AddPolicy(class="code-string">"ApiScope", policy => policy.RequireClaim(class="code-string">"scope", class="code-string">"api"));
var app = builder.Build();
class=class="code-string">"code-comment">// Apply globally to a route group
var api = app.MapGroup(class="code-string">"/api").RequireAuthorization(class="code-string">"ApiScope");
class=class="code-string">"code-comment">// Override for specific endpoints
api.MapGet(class="code-string">"/public/health", () => Results.Ok(class="code-string">"Healthy")).AllowAnonymous();
api.MapDelete(class="code-string">"/admin/cache", (ICacheService cache) => cache.Clear()).RequireAuthorization(class="code-string">"Admin");Centralized Validation with FluentValidation
class=class="code-string">"code-comment">// Register all validators from the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
class=class="code-string">"code-comment">// Generic validation filter applied per route group
public static RouteGroupBuilder WithValidation<T>(this RouteGroupBuilder group) where T : class
{
return group.AddEndpointFilter<ValidationFilter<T>>();
}Global Error Handling
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exception, class="code-string">"Unhandled exception");
context.Response.StatusCode = exception switch
{
NotFoundException => class="code-number">404,
ValidationException => class="code-number">400,
UnauthorizedAccessException => class="code-number">403,
_ => class="code-number">500
};
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = context.Response.StatusCode,
Title = exception switch
{
NotFoundException => class="code-string">"Resource not found",
ValidationException => class="code-string">"Validation failed",
_ => class="code-string">"An error occurred"
}
});
});
});In microservices I've built with Minimal APIs, combining a global exception handler with endpoint filters for validation covers the vast majority of error scenarios cleanly. The handler catches anything unexpected, and filters handle the predictable cases close to the endpoint.
Minimal APIs vs Controllers: A Decision Framework
| Aspect | Minimal APIs | Controllers |
|---|---|---|
| Boilerplate | Very low, lambda-based | Moderate, class + attributes |
| Startup performance | Faster, no reflection overhead | Slightly slower |
| Discoverability | All routes visible in one file or feature module | Scattered across controller classes |
| Model binding | Convention-based, simpler | Richer, more configurable |
| Filters/middleware | Endpoint filters (newer, simpler) | Action filters (mature, full-featured) |
| OpenAPI support | Built-in with TypedResults | Attribute-driven, well-established |
| Testing | Direct function calls | Requires controller instantiation |
| Team familiarity | Newer paradigm | Well-known MVC pattern |
| Large API surfaces | Needs discipline to organize | Naturally structured by class |
| Content negotiation | Manual setup | Built-in |
Choose Minimal APIs when: you're building focused microservices, BFFs, or any service where simplicity and startup speed matter more than MVC conventions. If your service has fewer than 30-40 endpoints, Minimal APIs will likely be cleaner.
Choose Controllers when: you have a large API surface with complex model binding, content negotiation, or a team deeply invested in MVC patterns. There's no shame in controllers; they're battle-tested and well-understood.
The hybrid approach also works: use Minimal APIs for simple CRUD and webhook endpoints while keeping controllers for complex areas that benefit from MVC conventions. ASP.NET Core supports both in the same project.
Common Minimal API Mistakes
1. Putting Business Logic in Handlers
class=class="code-string">"code-comment">// Bad: handler does everything
app.MapPost(class="code-string">"/orders", async (CreateOrderRequest req, AppDbContext db) =>
{
class=class="code-string">"code-comment">// class="code-number">30 lines of validation, business rules, and persistence
class=class="code-string">"code-comment">// This becomes unmaintainable fast
});
class=class="code-string">"code-comment">// Good: handler delegates to a service
app.MapPost(class="code-string">"/orders", async (CreateOrderRequest req, IOrderService service) =>
{
var result = await service.PlaceOrderAsync(req);
return result.IsSuccess
? TypedResults.Created($class="code-string">"/orders/{result.Value.Id}", result.Value)
: TypedResults.BadRequest(result.Error);
});2. Ignoring CancellationToken
class=class="code-string">"code-comment">// Bad: no cancellation support
app.MapGet(class="code-string">"/reports", async (IReportService service) =>
await service.GenerateAsync());
class=class="code-string">"code-comment">// Good: propagate cancellation
app.MapGet(class="code-string">"/reports", async (IReportService service, CancellationToken ct) =>
await service.GenerateAsync(ct));3. Not Using Route Groups
Defining every route at the top level with repeated `.RequireAuthorization()` and `.WithTags()` calls leads to noisy, hard-to-maintain code. Route groups exist to solve exactly this problem.
4. Overusing `Results.Ok()` for Everything
class=class="code-string">"code-comment">// Bad: always returns class="code-number">200, even for creation
app.MapPost(class="code-string">"/items", async (Item item, IItemService service) =>
Results.Ok(await service.CreateAsync(item)));
class=class="code-string">"code-comment">// Good: use semantically correct status codes
app.MapPost(class="code-string">"/items", async (Item item, IItemService service) =>
{
var created = await service.CreateAsync(item);
return TypedResults.Created($class="code-string">"/items/{created.Id}", created);
});5. Skipping Structured Logging
Minimal APIs don't have built-in action logging like MVC. Add request/response logging explicitly, either through middleware or endpoint filters, or you'll be debugging blind in production.
Conclusion
Minimal APIs are not a toy or a shortcut; they're a mature, production-ready approach to building HTTP services in .NET. The difference from controllers is syntax and ceremony, not engineering standards. You still need clean architecture, proper validation, structured error handling, and thoughtful endpoint organization.
The key insight is that Minimal APIs remove friction without removing capability. Every feature you rely on in controller-based ASP.NET Core, including DI, auth, OpenAPI, filters, and middleware, is fully available. What you gain is clarity: your service's entire surface area is visible, composable, and easy to reason about.
I can help evaluate whether Minimal APIs or Controllers fit your service best, and design an endpoint structure that scales cleanly.
Related Articles
What is .NET? A Modern Backend Development Guide
Learn what .NET is, how it works, and why enterprise teams choose it for backend development.
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
Microservices Architecture with .NET: Design and Implementation
Design microservices architecture with .NET. Service communication, Docker, and orchestration strategies.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch