5 Battle-Tested Essentials for Production-Ready Minimal APIs

From Prototype to Production

Your Minimal API works perfectly in development. Routes respond correctly, JSON serializes cleanly, and basic tests pass. But production demands more: proper error handling when databases fail, validation before bad data corrupts state, logging that helps debug issues at 2 AM, and health checks that tell load balancers when to route traffic elsewhere.

These production essentials aren't optional nice-to-haves. They're the difference between an API that runs reliably and one that wakes you up with alerts. Each pattern addresses a specific failure mode you'll eventually hit in production environments.

You'll learn 5 battle-tested patterns that make Minimal APIs production-ready: global exception handling, request validation, structured logging, health checks, and CORS configuration. Implement these before your first deployment.

Global Exception Handling

Unhandled exceptions in your endpoints return 500 Internal Server Error responses with stack traces and implementation details. This leaks information to attackers and confuses clients. Global exception handling middleware catches all unhandled errors, logs them properly, and returns consistent error responses.

The built-in exception handler middleware in .NET 8 provides a clean way to intercept exceptions before they reach clients. You can customize the response format, log errors with full context, and ensure sensitive data never appears in responses.

Here's production-grade exception handling that logs errors and returns clean JSON.

Program.cs
using Microsoft.AspNetCore.Diagnostics;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseExceptionHandler(exceptionHandlerApp =>
{
    exceptionHandlerApp.Run(async context =>
    {
        var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
        var exception = exceptionFeature?.Error;

        var logger = context.RequestServices
            .GetRequiredService<ILogger<Program>>();

        logger.LogError(exception,
            "Unhandled exception occurred processing {Path}",
            context.Request.Path);

        context.Response.StatusCode = 500;
        context.Response.ContentType = "application/json";

        await context.Response.WriteAsJsonAsync(new
        {
            Error = "An error occurred processing your request",
            TraceId = context.TraceIdentifier
        });
    });
});

app.MapGet("/error-test", () =>
{
    throw new InvalidOperationException("Test exception");
});

app.Run();

The middleware intercepts exceptions, logs them with request context, and returns a generic error message. The TraceId helps correlate client reports with server logs. Never expose exception messages or stack traces to clients in production.

Request Validation with Filters

Validating input before processing prevents corrupt data from entering your system. Endpoint filters let you extract validation logic from handlers and apply it consistently across endpoints. This keeps your business logic clean and your validation rules centralized.

You can use data annotations on DTOs and validate them in a filter, or integrate libraries like FluentValidation for more complex rules. The filter rejects invalid requests with detailed error messages before your handler executes.

Here's a validation filter using data annotations.

Program.cs
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/products", (ProductRequest request) =>
{
    return Results.Created($"/products/{Guid.NewGuid()}", request);
})
.AddEndpointFilter(async (context, next) =>
{
    var request = context.Arguments.OfType<ProductRequest>().FirstOrDefault();

    if (request is null)
        return Results.BadRequest(new { Error = "Request body required" });

    var validationResults = new List<ValidationResult>();
    var validationContext = new ValidationContext(request);

    if (!Validator.TryValidateObject(request, validationContext,
        validationResults, validateAllProperties: true))
    {
        var errors = validationResults.ToDictionary(
            v => v.MemberNames.FirstOrDefault() ?? "general",
            v => v.ErrorMessage ?? "Validation failed");

        return Results.BadRequest(new { Errors = errors });
    }

    return await next(context);
});

app.Run();

record ProductRequest(
    [Required][StringLength(100)] string Name,
    [Range(0.01, 100000)] decimal Price,
    [StringLength(500)] string? Description);

The filter validates the request object before calling the handler. Invalid requests return 400 Bad Request with a dictionary of field-level errors. Clients get actionable feedback about what to fix. Your handler only sees valid data.

Structured Logging

Console.WriteLine doesn't cut it in production. You need structured logs you can query by request ID, user, endpoint, or error type. ASP.NET Core's built-in logging framework supports structured data and integrates with tools like Application Insights, Seq, or Elasticsearch.

Log meaningful business events at Information level. Use Warning for recoverable issues and Error for failures that need attention. Always include correlation IDs so you can trace requests across services and logs.

Here's how to add structured logging to your endpoints.

Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/orders", async (OrderRequest request, ILogger<Program> logger) =>
{
    logger.LogInformation(
        "Creating order for customer {CustomerId} with {ItemCount} items",
        request.CustomerId,
        request.Items.Count);

    try
    {
        var orderId = Guid.NewGuid();

        logger.LogInformation(
            "Order {OrderId} created successfully for customer {CustomerId}",
            orderId,
            request.CustomerId);

        return Results.Created($"/orders/{orderId}", new { OrderId = orderId });
    }
    catch (Exception ex)
    {
        logger.LogError(ex,
            "Failed to create order for customer {CustomerId}",
            request.CustomerId);
        throw;
    }
});

app.Run();

record OrderRequest(int CustomerId, List<OrderItem> Items);
record OrderItem(int ProductId, int Quantity);

Structured parameters like CustomerId and OrderId are extractable fields in log queries. You can filter all orders from customer 42 or trace a specific order through your system. Use parameterized logging instead of string interpolation to preserve structure.

Health Checks for Readiness and Liveness

Load balancers and orchestrators like Kubernetes need to know if your API is healthy. Health checks provide standard endpoints that return 200 when the service is ready to accept traffic and 503 when it's not. This prevents routing requests to failed instances.

Implement two types: readiness checks that verify dependencies are available, and liveness checks that confirm the process is still responding. Readiness fails during startup until the database connects. Liveness only fails if the process is deadlocked or crashed.

Here's how to add health checks with database verification.

Program.cs
using Microsoft.Extensions.Diagnostics.HealthChecks;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy())
    .AddCheck<DatabaseHealthCheck>("database");

var app = builder.Build();

app.MapHealthChecks("/health/ready");
app.MapHealthChecks("/health/live", new()
{
    Predicate = check => check.Name == "self"
});

app.Run();

public class DatabaseHealthCheck : IHealthCheck
{
    private readonly ILogger<DatabaseHealthCheck> _logger;

    public DatabaseHealthCheck(ILogger<DatabaseHealthCheck> logger)
    {
        _logger = logger;
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            await Task.Delay(10, cancellationToken);

            _logger.LogDebug("Database health check passed");
            return HealthCheckResult.Healthy("Database is accessible");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Database health check failed");
            return HealthCheckResult.Unhealthy(
                "Database is not accessible",
                ex);
        }
    }
}

The /health/ready endpoint runs all checks including database connectivity. Kubernetes uses this during startup to know when the pod can receive traffic. The /health/live endpoint only checks if the process responds. Keep health checks fast, under one second, to avoid false failures.

CORS Configuration for Frontend Access

If your API serves browser-based frontends, you need CORS configured correctly. Browsers block requests from different origins unless the API explicitly allows them. Wrong CORS settings either expose your API to all origins (security risk) or block legitimate clients (broken app).

Configure allowed origins from settings, not hardcoded values. Use specific origins in production, never AllowAnyOrigin. Set credentials carefully since they require exact origin matches, not wildcards.

Here's production-safe CORS configuration.

Program.cs
var builder = WebApplication.CreateBuilder(args);

var allowedOrigins = builder.Configuration
    .GetSection("Cors:AllowedOrigins")
    .Get<string[]>() ?? Array.Empty<string>();

builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.WithOrigins(allowedOrigins)
              .AllowAnyMethod()
              .AllowAnyHeader()
              .AllowCredentials();
    });
});

var app = builder.Build();

app.UseCors();

app.MapGet("/data", () => new { Message = "CORS enabled" });

app.Run();
appsettings.json
{
  "Cors": {
    "AllowedOrigins": [
      "https://myapp.com",
      "https://www.myapp.com"
    ]
  }
}

Origins come from configuration so you can adjust them per environment. The policy allows any method and header from the specified origins. Test CORS thoroughly with your actual frontend domains before deploying. A single mistake breaks browser requests silently.

Implement Production Essentials

Build an API with all 5 production patterns enabled. You'll see how they work together to create a robust service.

Steps

  1. dotnet new web -n ProductionApi
  2. cd ProductionApi
  3. Replace Program.cs with the code below
  4. dotnet run
  5. Test health: curl http://localhost:5000/health/ready
  6. Test error handling: curl http://localhost:5000/error
ProductionApi.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
using Microsoft.AspNetCore.Diagnostics;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHealthChecks();
builder.Services.AddCors();

var app = builder.Build();

app.UseExceptionHandler(exApp =>
{
    exApp.Run(async context =>
    {
        var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
        var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();

        logger.LogError(exception, "Unhandled exception on {Path}", context.Request.Path);

        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(new
        {
            Error = "An error occurred",
            TraceId = context.TraceIdentifier
        });
    });
});

app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());

app.MapHealthChecks("/health/ready");
app.MapHealthChecks("/health/live");

app.MapGet("/data", (ILogger<Program> logger) =>
{
    logger.LogInformation("Data endpoint called");
    return new { Message = "Production-ready API", Timestamp = DateTime.UtcNow };
});

app.MapGet("/error", () =>
{
    throw new InvalidOperationException("Test error for exception handling");
});

app.Run();

Output

The health check returns HTTP 200 with a Healthy status. The error endpoint triggers the exception handler, logs the error, and returns a clean JSON response without stack traces. Check your console for structured log output showing request details.

FAQ

Should I use global exception handling or try-catch in endpoints?

Use both strategically. Global middleware catches unexpected errors and prevents 500 responses from leaking details. Use try-catch in endpoints for expected errors like not-found or validation failures where you need specific response codes. The middleware is your safety net for everything else.

How do I validate request bodies in Minimal APIs?

Create an endpoint filter that validates using FluentValidation or data annotations before the handler executes. Extract the request object from context, validate it, and return 400 with error details if validation fails. Otherwise, call next to continue processing. This keeps validation separate from business logic.

What should health checks actually verify?

Check database connectivity, external API availability, and critical dependencies. Don't test every feature, just verify the service can fulfill requests. Keep checks fast, under one second. Use /health/ready for startup checks and /health/live for runtime monitoring. Fail fast if dependencies are down.

How much logging is too much?

Log at Information level for business events and errors. Use Debug for troubleshooting that you disable in production. Avoid logging in tight loops or per-request unless debugging specific issues. Include correlation IDs to trace requests across services. Monitor log volume and tune levels based on value vs cost.

Do I need custom metrics for a small API?

Start with built-in ASP.NET Core metrics for request count, duration, and status codes. Add custom metrics only for business-specific measurements like orders processed, payments succeeded, or queue depth. Too many metrics create noise. Focus on what helps you debug production issues or measure business impact.

What's the minimum for CORS in production?

Never use AllowAnyOrigin in production. List specific allowed origins from configuration. Set allowed methods and headers explicitly. Use credentials carefully as they require specific origins, not wildcards. Test CORS with your actual frontend domains before deploying. Wrong CORS breaks browsers silently for users.

Back to Articles