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> or TypedResults instead of IResult so the OpenAPI document and the client generator know the response types.
  • Bind CancellationToken from 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; });
});
ConcernMechanismNote
Cross-cutting workMiddlewareRegistered in order; the first registered runs first
Per-endpoint workEndpoint filterSees the bound parameters and can short-circuit
Error shapeAddProblemDetails plus UseExceptionHandlerOne RFC 9457 body for every failure
AuthenticationAddAuthentication with a schemeJWT bearer for APIs, cookies for server-rendered pages
Rate limitingAddRateLimiterProtects 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.

Dependency injection and configuration Data access with Entity Framework Core

Last refreshed 2026-09-18.