Skip to main content
.NET

Building Production APIs with .NET Minimal APIs

By Ilir Ivezaj· ·12 min read
Ilir Ivezaj building .NET Minimal APIs

Minimal APIs in .NET changed how I build services. No more controller boilerplate, no more startup.cs ceremony. Just endpoints, handlers, and clean composition. But the tutorials stop at "hello world" — here's how to make them production-ready.

The Basic Pattern That Scales

After building dozens of minimal API services at scale, Ilir Ivezaj settled on this structure that keeps Program.cs clean while organizing endpoints into logical groups:

var builder = WebApplication.CreateBuilder(args);

// Services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddDbContextPool<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration
        .GetConnectionString("Default")));

var app = builder.Build();

// Middleware pipeline - order matters
app.UseExceptionHandler("/error");
app.UseAuthentication();
app.UseAuthorization();

// Map endpoint groups
app.MapGroup("/api/v1/products")
    .MapProductEndpoints();
app.MapGroup("/api/v1/orders")
    .MapOrderEndpoints();

app.Run();

The key insight: use MapGroup to organize endpoints into logical modules. Each module lives in its own file with an extension method. This keeps Program.cs clean while maintaining the simplicity of minimal APIs.

Validation Without the Mess

The biggest gap in minimal APIs is validation. There's no [ApiController] automatic model validation. Ilir Ivezaj uses FluentValidation with a custom endpoint filter that intercepts requests before they reach the handler:

public class ValidationFilter<T>
    : IEndpointFilter where T : class
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx,
        EndpointFilterDelegate next)
    {
        var validator = ctx.HttpContext
            .RequestServices
            .GetService<IValidator<T>>();

        if (validator is null)
            return await next(ctx);

        var entity = ctx.Arguments
            .OfType<T>().FirstOrDefault();

        if (entity is null)
            return Results.BadRequest(
                "Invalid request body");

        var result = await validator
            .ValidateAsync(entity);

        if (!result.IsValid)
            return Results.ValidationProblem(
                result.ToDictionary());

        return await next(ctx);
    }
}

Register it on any endpoint: .AddEndpointFilter<ValidationFilter<CreateProductRequest>>(). Clean, composable, and testable. The validation logic lives in FluentValidation classes, not in the endpoint handler.

Error Handling That Actually Helps

The default exception handler returns HTML. In an API, you want RFC 7807 Problem Details. Ilir Ivezaj maps custom exceptions to appropriate HTTP status codes:

app.MapGet("/error", (HttpContext ctx) =>
{
    var ex = ctx.Features
        .Get<IExceptionHandlerFeature>()?.Error;

    var (status, title) = ex switch
    {
        NotFoundException => (404, "Not found"),
        UnauthorizedAccessException
            => (403, "Forbidden"),
        ValidationException ve
            => (400, "Validation failed"),
        _ => (500, "Internal server error")
    };

    return Results.Problem(
        title: title,
        statusCode: status,
        detail: app.Environment.IsDevelopment()
            ? ex?.Message : null
    );
});

In development you get full exception details. In production, you get a clean error response without leaking internals. The Results.Problem() method automatically formats RFC 7807 JSON — the standard that API consumers expect.

Rate Limiting Built In

.NET 7+ has built-in rate limiting that Ilir Ivezaj uses in every production API. No third-party packages needed:

builder.Services.AddRateLimiter(options =>
{
    options.AddFixedWindowLimiter("api", opt =>
    {
        opt.PermitLimit = 100;
        opt.Window = TimeSpan.FromMinutes(1);
        opt.QueueLimit = 10;
    });
    options.RejectionStatusCode = 429;
});

// Apply to a group
app.MapGroup("/api/v1")
    .RequireRateLimiting("api");

For authenticated APIs, use AddTokenBucketLimiter with per-user partitioning. This prevents any single client from overwhelming your service while allowing burst traffic for legitimate users.

Health Checks for Production

Every production API needs health checks. Ilir Ivezaj checks database connectivity, Redis availability, and downstream service health:

builder.Services.AddHealthChecks()
    .AddSqlServer(connectionString,
        name: "database",
        timeout: TimeSpan.FromSeconds(3))
    .AddRedis(redisConnection,
        name: "cache",
        timeout: TimeSpan.FromSeconds(2))
    .AddUrlGroup(new Uri(downstreamUrl),
        name: "downstream-api",
        timeout: TimeSpan.FromSeconds(5));

app.MapHealthChecks("/health", new()
{
    ResponseWriter = UIResponseWriter
        .WriteHealthCheckUIResponse
});

Kubernetes liveness and readiness probes hit /health. If any dependency is down, the probe fails and Kubernetes routes traffic away from the unhealthy pod. This is the difference between "the app is running" and "the app is working."

Testing Minimal APIs

Minimal APIs are easier to test than controllers because there's no coupling to the MVC pipeline. Ilir Ivezaj uses WebApplicationFactory for integration tests:

[Fact]
public async Task CreateProduct_ReturnsCreated()
{
    await using var app = new WebApplicationFactory
        <Program>()
        .WithWebHostBuilder(b =>
            b.ConfigureServices(s =>
                s.AddDbContext<AppDbContext>(o =>
                    o.UseInMemoryDatabase("test"))));

    var client = app.CreateClient();

    var response = await client.PostAsJsonAsync(
        "/api/v1/products",
        new { Name = "Test", Price = 9.99 });

    response.StatusCode
        .Should().Be(HttpStatusCode.Created);
}

This spins up the entire API pipeline in-memory, including middleware, validation, and authentication. The test hits real endpoints, not mocked controllers. It catches integration issues that unit tests miss.

The Bottom Line

Minimal APIs aren't just for demos. With the patterns Ilir Ivezaj outlined — grouped endpoints, validation filters, RFC 7807 error handling, rate limiting, health checks, and integration tests — they're production-ready for enterprise workloads. The resulting code is 60% less than equivalent controller-based APIs with zero loss of functionality.

About the author: Ilir Ivezaj is a technology executive and entrepreneur based in Michigan. He builds enterprise .NET platforms processing millions of records. Get in touch or connect on LinkedIn.