Minimal APIs in ASP.NET Core: Lightweight APIs Without the Boilerplate
A practical, in-depth guide to Minimal APIs — the lightweight way to build HTTP APIs in ASP.NET Core, ideal for microservices, small backend services, and high-throughput endpoints.
Table of Contents
- Introduction
- The Basics
- Routing and Route Parameters
- Parameter Binding
- Request Validation
- Typed Results
- Route Groups and Organization
- Dependency Injection in Minimal APIs
- Filters
- OpenAPI / Swagger Integration
- Minimal APIs vs. Controllers
- Performance Characteristics
- Quick Reference Table
- Conclusion
Introduction
Minimal APIs, introduced in .NET 6, are a way to define HTTP endpoints directly in Program.cs (or nearby files) without controllers, attributes, or the MVC conventions that come with them. They were designed for a specific gap: not every service needs full MVC — a small microservice, a health-check endpoint, or a focused internal API often just needs a handful of routes wired directly to logic.
The pitch is simple: fewer files, less ceremony, faster startup — while still supporting the things real APIs need: validation, DI, authorization, OpenAPI docs, and strongly-typed responses.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello, World!");
app.Run();
That's a complete, runnable web API. No controller class, no attributes, no separate Startup.cs.
1. The Basics
The four core HTTP verbs
var app = builder.Build();
app.MapGet("/products", () => products);
app.MapPost("/products", (Product product) => { products.Add(product); return Results.Created($"/products/{product.Id}", product); });
app.MapPut("/products/{id}", (int id, Product updated) => { /* update logic */ });
app.MapDelete("/products/{id}", (int id) => { /* delete logic */ });
app.Run();
Each Map* method takes a route pattern and a delegate (lambda or method group). The delegate's parameters are automatically bound from the route, query string, request body, headers, or DI container — ASP.NET Core infers the source based on the parameter's type and position.
Returning results
You can return plain strings, POCOs (serialized to JSON automatically), or explicit IResult values via the Results static class:
app.MapGet("/status", () => "OK"); // 200, text/plain
app.MapGet("/config", () => new { Version = "1.0" }); // 200, JSON
app.MapGet("/secret", () => Results.Unauthorized()); // 401
app.MapGet("/report/{id}", (int id) =>
id > 0 ? Results.Ok(GetReport(id)) : Results.BadRequest("Invalid id"));
2. Routing and Route Parameters
Route constraints
app.MapGet("/products/{id:int}", (int id) => GetProduct(id));
app.MapGet("/orders/{orderId:guid}", (Guid orderId) => GetOrder(orderId));
app.MapGet("/archive/{year:int:min(2000)}/{month:int:range(1,12)}", (int year, int month) => GetArchive(year, month));
Constraints (:int, :guid, :min(), :range(), :alpha, :regex(), etc.) let invalid requests fail at the routing layer with a 404, before your handler even runs.
Optional parameters and defaults
app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) =>
{
return Search(query, page, pageSize);
});
A request to /search?query=laptop works, filling in page and pageSize from their C# default values if omitted from the query string.
Wildcard / catch-all routes
app.MapGet("/files/{*path}", (string path) => ServeFile(path));
3. Parameter Binding
Minimal APIs infer where a parameter comes from based on type and convention, but you can always be explicit.
| Source | How it's inferred | Explicit attribute |
|---|---|---|
| Route values | Parameter name matches a { } segment |
[FromRoute] |
| Query string | Simple type (string, int, bool, etc.) not matching a route segment | [FromQuery] |
| Request body | Complex type (class/record), only one per endpoint | [FromBody] |
| Headers | — | [FromHeader] |
| DI container | Type is a registered service | [FromServices] |
| Form data |
IFormCollection, IFormFile
|
[FromForm] |
app.MapPost("/upload", async (IFormFile file, [FromServices] IStorageService storage) =>
{
await storage.SaveAsync(file);
return Results.Ok();
});
Binding from JSON body
public record CreateProductRequest(string Name, decimal Price, string Category);
app.MapPost("/products", (CreateProductRequest request, IProductRepository repo) =>
{
var product = repo.Add(request.Name, request.Price, request.Category);
return Results.Created($"/products/{product.Id}", product);
});
Custom binding with BindAsync (.NET 7+)
For types that need custom parsing logic (e.g., from a cookie or a composite query format), implement a static BindAsync method:
public record struct SortOptions(string Field, bool Descending)
{
public static ValueTask<SortOptions?> BindAsync(HttpContext context, ParameterInfo parameter)
{
var value = context.Request.Query["sort"].ToString();
if (string.IsNullOrEmpty(value)) return ValueTask.FromResult<SortOptions?>(null);
var descending = value.StartsWith('-');
var field = descending ? value[1..] : value;
return ValueTask.FromResult<SortOptions?>(new SortOptions(field, descending));
}
}
app.MapGet("/products", (SortOptions? sort) => GetSortedProducts(sort));
4. Request Validation
Minimal APIs don't include the automatic model-state validation that [ApiController] provides for MVC, so validation is typically explicit — which keeps behavior predictable but means you opt in.
Manual validation
app.MapPost("/products", (CreateProductRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Name"] = ["Name is required."]
});
if (request.Price <= 0)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Price"] = ["Price must be greater than zero."]
});
// ... create product
return Results.Created();
});
DataAnnotations + a validation filter (.NET 8+ approach)
public record CreateProductRequest(
[property: Required, MinLength(3)] string Name,
[property: Range(0.01, double.MaxValue)] decimal Price);
app.MapPost("/products", (CreateProductRequest request) => Results.Created())
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
Where ValidationFilter<T> is a small reusable endpoint filter that runs Validator.TryValidateObject before the handler executes — this pattern lets you centralize validation logic instead of repeating if checks in every handler. (.NET 10 also introduces built-in support for validating annotated types automatically in minimal APIs, reducing the need to hand-write this filter.)
FluentValidation (popular third-party option)
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
RuleFor(x => x.Name).NotEmpty().MinimumLength(3);
RuleFor(x => x.Price).GreaterThan(0);
}
}
Many teams pair minimal APIs with FluentValidation via an endpoint filter, since it scales better than DataAnnotations for complex, cross-field rules.
5. Typed Results
Untyped IResult return values don't tell the OpenAPI generator (or callers) what status codes and shapes to expect. TypedResults (added in .NET 7) fixes this.
app.MapGet("/products/{id}", Results<Ok<Product>, NotFound> (int id, IProductRepository repo) =>
{
var product = repo.GetById(id);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
});
Benefits of TypedResults over Results:
- Accurate OpenAPI/Swagger docs — the possible response types and status codes are visible to the type system, not just at runtime.
- Compile-time safety — you can't accidentally return a type that isn't declared in the method signature.
- Easier unit testing — you can assert on the concrete result type without spinning up an HTTP pipeline.
[Fact]
public void GetById_ReturnsNotFound_WhenMissing()
{
var result = Endpoint.GetProduct(999, new FakeRepository());
Assert.IsType<NotFound>(result.Result);
}
6. Route Groups and Organization
As an API grows past a handful of endpoints, Program.cs can get crowded. Route groups (.NET 7+) and extension methods keep things tidy without reaching for full MVC.
Grouping related endpoints
var products = app.MapGroup("/products").WithTags("Products");
products.MapGet("/", GetAllProducts);
products.MapGet("/{id:int}", GetProductById);
products.MapPost("/", CreateProduct).RequireAuthorization();
products.MapPut("/{id:int}", UpdateProduct).RequireAuthorization();
products.MapDelete("/{id:int}", DeleteProduct).RequireAuthorization("AdminOnly");
Anything applied to the group (auth, filters, tags, rate limits) cascades to every endpoint inside it.
Extracting endpoints into their own files
// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProductEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", GetAllProducts);
group.MapGet("/{id:int}", GetProductById);
group.MapPost("/", CreateProduct);
return group;
}
private static async Task<Ok<List<Product>>> GetAllProducts(IProductRepository repo) =>
TypedResults.Ok(await repo.GetAllAsync());
private static async Task<Results<Ok<Product>, NotFound>> GetProductById(int id, IProductRepository repo)
{
var product = await repo.GetByIdAsync(id);
return product is not null ? TypedResults.Ok(product) : TypedResults.NotFound();
}
private static async Task<Created<Product>> CreateProduct(CreateProductRequest request, IProductRepository repo)
{
var product = await repo.AddAsync(request);
return TypedResults.Created($"/products/{product.Id}", product);
}
}
// Program.cs
app.MapGroup("/products").MapProductEndpoints();
This pattern — extension methods per feature area — is the community-standard way to keep minimal APIs organized without MVC's folder/attribute conventions, and it scales to dozens of endpoint groups cleanly.
7. Dependency Injection in Minimal APIs
Minimal API handlers get services the same way controllers do — via the DI container — just injected as method parameters instead of constructor parameters.
builder.Services.AddScoped<IProductRepository, SqlProductRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();
app.MapGet("/products", (IProductRepository repo) => repo.GetAll());
Keyed services (.NET 8+)
app.MapPost("/notify", ([FromKeyedServices("sms")] INotifier notifier, string message) =>
{
notifier.Send(message);
return Results.Ok();
});
8. Filters
Endpoint filters (.NET 7+) are minimal APIs' answer to MVC's action filters — a way to run logic before/after a handler without duplicating it across endpoints.
public class LoggingFilter : IEndpointFilter
{
private readonly ILogger<LoggingFilter> _logger;
public LoggingFilter(ILogger<LoggingFilter> logger) => _logger = logger;
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
_logger.LogInformation("Executing {Endpoint}", context.HttpContext.Request.Path);
var result = await next(context);
_logger.LogInformation("Executed {Endpoint} -> {Result}", context.HttpContext.Request.Path, result);
return result;
}
}
app.MapPost("/products", CreateProduct).AddEndpointFilter<LoggingFilter>();
Inline filters
app.MapPost("/products", CreateProduct)
.AddEndpointFilter(async (context, next) =>
{
var request = context.GetArgument<CreateProductRequest>(0);
if (request.Price <= 0)
return Results.BadRequest("Price must be positive.");
return await next(context);
});
Filters compose in the order they're added, and can short-circuit the pipeline (skip the handler entirely) — useful for validation, logging, and cross-cutting checks like feature flags.
9. OpenAPI / Swagger Integration
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
Enriching endpoint metadata
app.MapGet("/products/{id}", GetProductById)
.WithName("GetProductById")
.WithSummary("Retrieves a single product by its ID")
.WithTags("Products")
.Produces<Product>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
Using TypedResults (Section 5) instead of Results means much of this metadata — the possible status codes and response shapes — is inferred automatically, reducing how much you need to declare by hand. .NET 9 also ships a built-in OpenAPI document generator (Microsoft.AspNetCore.OpenApi) as a lighter-weight alternative to Swashbuckle for teams that only need the raw OpenAPI JSON/YAML output.
10. Minimal APIs vs. Controllers
| Aspect | Minimal APIs | MVC Controllers |
|---|---|---|
| File structure | Delegates in Program.cs or extension methods |
Controller classes with attributes |
| Boilerplate | Very low | Moderate — base class, attributes, routing conventions |
| Validation | Explicit (manual, filters, or FluentValidation) | Automatic via [ApiController] model-state validation |
| Startup performance | Faster — less reflection-based discovery | Slightly slower, more metadata to build |
| Best fit | Microservices, small APIs, high-throughput endpoints, serverless functions | Larger apps, teams wanting strong conventions, apps mixing API + server-rendered views |
| View rendering | Not supported directly (API-only) | Full support via Razor views |
| Native AOT compatibility | Excellent (designed with AOT in mind) | Limited — MVC relies heavily on reflection |
| Learning curve | Lower for simple cases | Steeper, but scales better with strict team conventions |
A practical rule of thumb
- Small service, a handful of endpoints, containerized, or targeting Native AOT? → Minimal APIs.
- Large application with dozens of resources, complex validation rules, or mixed API+UI needs? → Controllers (optionally alongside minimal APIs for a few lightweight endpoints — the two can coexist in the same app).
11. Performance Characteristics
Minimal APIs were explicitly designed to reduce the overhead between "request arrives" and "your code runs":
- Less reflection at startup — routing metadata for minimal APIs is built more directly than the attribute-scanning MVC relies on, which shows up as measurably faster app startup — a meaningful win for serverless functions and container cold starts.
- No MVC filter pipeline overhead — minimal APIs use the lighter endpoint-filter pipeline described above rather than MVC's larger action/result filter pipeline.
- First-class Native AOT support — because minimal APIs avoid runtime reflection-heavy model binding by default, they compile and run well under Native AOT, where startup time and memory footprint drop dramatically:
dotnet publish -r linux-x64 -c Release /p:PublishAot=true
-
Source-generated JSON serialization — pairing minimal APIs with
System.Text.Jsonsource generators ([JsonSerializable]context classes) avoids reflection-based serialization entirely, which matters most in Native AOT scenarios where reflection-based serialization isn't available at all.
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
internal partial class AppJsonContext : JsonSerializerContext { }
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
Quick Reference Table
| Feature | Introduced | Purpose |
|---|---|---|
MapGet/MapPost/etc. |
.NET 6 | Define routes without controllers |
| Automatic parameter binding | .NET 6 | Infer route/query/body sources from method signature |
Route groups (MapGroup) |
.NET 7 | Share config across related endpoints |
Typed results (TypedResults) |
.NET 7 | Strongly-typed, OpenAPI-friendly responses |
| Endpoint filters | .NET 7 | Cross-cutting logic (validation, logging) without duplication |
BindAsync custom binding |
.NET 7 | Custom parameter parsing logic |
| Keyed DI services | .NET 8 | Multiple implementations of one interface |
Built-in Microsoft.AspNetCore.OpenApi
|
.NET 9 | Lightweight OpenAPI document generation |
| JSON source generation | .NET 6+, essential for AOT | Reflection-free serialization |
| Native AOT support | .NET 8 | Fast cold starts, low memory footprint |
Conclusion
Minimal APIs aren't a "lesser" version of MVC — they're a different default, optimized for the reality that a large share of modern backend services are small, focused, and deployed as independently scaled units (microservices, serverless functions, sidecars). By stripping away controllers, attributes, and MVC's filter pipeline, they cut the distance between a route definition and the code that handles it — while still supporting the things production APIs need: validation via filters, strongly-typed responses, DI, and OpenAPI docs.
The right choice isn't binary, either — minimal APIs and controllers coexist in the same ASP.NET Core app, so many teams use minimal APIs for lightweight, high-traffic endpoints and controllers for larger, more conventions-heavy parts of the same system.
Found this useful? Feel free to star the repo, open an issue with corrections, or share how you've organized minimal APIs in a larger codebase.
Top comments (0)