Building a web API with ASP.NET Core
Minimal APIs, routing, validation, problem details, middleware and OpenAPI — an API you can actually ship.
Minimal APIs
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi(); // built-in OpenAPI document
builder.Services.AddProblemDetails(); // RFC 9457 error bodies
builder.Services.AddScoped<IOrderService, OrderService>();
var app = builder.Build();
app.UseExceptionHandler(); // converts unhandled exceptions to problem details
app.UseStatusCodePages();
// a route group keeps the prefix and the metadata in one place
var orders = app.MapGroup("/api/orders").WithTags("Orders");
orders.MapGet("/{id:guid}", async (Guid id, IOrderService svc, CancellationToken ct) =>
{
var order = await svc.FindAsync(id, ct);
return order is null ? Results.NotFound() : Results.Ok(order);
})
.WithName("GetOrder")
.Produces<OrderResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound);
orders.MapPost("/", async ([FromBody] CreateOrderRequest request, IOrderService svc, CancellationToken ct) =>
{
// validate explicitly and return the standard error shape
List<ValidationResult> problems = [];
if (!Validator.TryValidateObject(request, new ValidationContext(request), problems, validateAllProperties: true))
return Results.ValidationProblem(problems.ToDictionary(
p => p.MemberNames.FirstOrDefault() ?? "request", p => new[] { p.ErrorMessage ?? "invalid" }));
OrderResponse created = await svc.CreateAsync(request, ct);
return Results.CreatedAtRoute("GetOrder", new { id = created.Id }, created);
})
.Accepts<CreateOrderRequest>("application/json");
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).ExcludeFromDescription();
if (app.Environment.IsDevelopment()) app.MapOpenApi();
app.Run();
public sealed record CreateOrderRequest(
[property: Required, StringLength(32, MinimumLength = 1)] string Sku,
[property: Range(1, 1000)] int Quantity);
public sealed record OrderResponse(Guid Id, string Sku, int Quantity, decimal Price, DateTimeOffset CreatedAt);- Return
Results<T1, T2>orTypedResultsinstead ofIResultso the OpenAPI document and the client generator know the response types. - Bind
CancellationTokenfrom the request. When the client disconnects, the token is cancelled and your database call can stop instead of finishing work nobody will read. {id:guid}applies a route constraint, so a malformed id returns 404 without running your handler.- Minimal APIs are not a toy: controllers only add value when you need filters, model binding conventions or a large team convention.
Middleware, errors and CORS
// a custom middleware: do work before and after the pipeline
app.Use(async (context, next) =>
{
string traceId = context.TraceIdentifier;
using (app.Logger.BeginScope(new Dictionary<string, object> { ["traceId"] = traceId }))
{
context.Response.Headers["X-Trace-Id"] = traceId;
await next(); // call the rest of the pipeline
}
});
// order matters: routing first, then authn, then authz, then endpoints
app.UseHttpsRedirection();
app.UseCors("spa");
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapControllers();
builder.Services.AddCors(options => options.AddPolicy("spa", policy =>
policy.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()));
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("api", o => { o.Window = TimeSpan.FromMinutes(1); o.PermitLimit = 100; });
});| Concern | Mechanism | Note |
|---|---|---|
| Cross-cutting work | Middleware | Registered in order; the first registered runs first |
| Per-endpoint work | Endpoint filter | Sees the bound parameters and can short-circuit |
| Error shape | AddProblemDetails plus UseExceptionHandler | One RFC 9457 body for every failure |
| Authentication | AddAuthentication with a scheme | JWT bearer for APIs, cookies for server-rendered pages |
| Rate limiting | AddRateLimiter | Protects expensive endpoints, not a substitute for auth |
Middleware order is the pipeline order. Authentication must come before authorisation, and anything that writes the response body (exception handling) must be registered early enough to catch what comes after it.
Authentication and the deployable shape
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
options.TokenValidationParameters = new()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1),
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("admin", policy => policy.RequireClaim("role", "admin"));
app.MapDelete("/api/orders/{id:guid}", (Guid id, IOrderService svc, CancellationToken ct) => svc.DeleteAsync(id, ct))
.RequireAuthorization("admin");
// connection string from configuration, never from source
string cs = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("ConnectionStrings:Default is required");💡
Put the environment name in
ASPNETCORE_ENVIRONMENT and gate the development-only middleware on IsDevelopment(). A leaked developer exception page in production prints your stack traces and configuration to the internet.FAQ
Minimal APIs or controllers?
Minimal APIs for new services: less ceremony, better performance and first-class OpenAPI support. Controllers remain reasonable for large existing codebases and for teams that rely on filters and model-binding conventions.
How do I return the right status code for a validation failure?
Use
Results.ValidationProblem, which produces the standard 400 body with per-field errors. Returning a bare BadRequest("invalid") forces every client to parse a message.Related
Dependency injection and configuration Data access with Entity Framework Core
Last refreshed 2026-09-18.