✨ Hands-On Tutorial

ASP.NET Core / Minimal APIs in the Real World: Filters, Validation, Versioning & Rate Limiting

Your API handles 10,000 requests per second. Input validation runs in a single filter. Rate limiting protects your database. Three API versions coexist without breaking clients. This isn't enterprise bloat. It's Minimal APIs done right.

Most Minimal API tutorials show you MapGet and MapPost, then leave you hanging. Production APIs need validation, versioning, authentication, and rate limiting. You could build each feature from scratch. Or you could learn the patterns that scale. This tutorial shows you the production patterns.

What You'll Build

You'll build a production-ready Orders API that demonstrates every pattern:

  • Endpoint filters for cross-cutting concerns like timing, logging, and auth checks
  • FluentValidation integration that returns RFC-7807 ProblemDetails consistently
  • API versioning with URL segments (/v1, /v2) and proper deprecation headers
  • Rate limiting with per-user and per-IP policies to protect resources
  • JWT authentication with scope-based authorization policies
  • OpenAPI documentation with versioned groups, examples, and tags
  • Response and output caching for optimal performance
  • Observability with OpenTelemetry, structured logging, and activity tracing

Why Minimal APIs?

Minimal APIs launched in .NET 6. They're not a replacement for MVC controllers. They're a simpler, faster option for focused APIs. Less ceremony, less overhead, better performance.

When to Choose Minimal APIs

Choose Minimal APIs for microservices, new APIs, and projects where performance matters. They start 30% faster than MVC and use less memory. The code is cleaner when you don't need complex model binding or global filters.

Stick with MVC controllers if you have existing MVC apps, need action filters across dozens of endpoints, or rely on complex model binding scenarios. Minimal APIs excel at focused, high-performance APIs where each endpoint is explicit.

Performance Benefits

Minimal APIs skip the controller activation overhead. No controller instances. No action method discovery. Just a direct route to your handler. This saves milliseconds per request. At scale, that's thousands of requests per second.

MVC vs Minimal APIs

MVC: Best for complex apps with many endpoints sharing behavior. Built-in model binding and validation. Action filters. View rendering. Minimal APIs: Best for focused APIs. Less overhead. Explicit configuration. Better for microservices and high-throughput scenarios.

Setup & First Endpoint

Start with a minimal API project. You'll add complexity incrementally. Each pattern builds on the previous one.

Create the Project

Use the webapi template with minimal configuration. This gives you a clean Program.cs without controllers.

Terminal
dotnet new webapi -n OrdersApi -minimal
cd OrdersApi
dotnet add package FluentValidation.DependencyInjectionExtensions
dotnet add package Asp.Versioning.Http
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

First Endpoints: CRUD Basics

Start with basic GET and POST endpoints. Use typed results for clarity. Results like Ok, Created, and NotFound make intent obvious and enable OpenAPI generation.

Program.cs - Basic Endpoints
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);

// In-memory storage for demo
var orders = new List();

var app = builder.Build();

app.MapGet("/orders", () => TypedResults.Ok(orders))
    .WithName("GetOrders")
    .WithTags("Orders");

app.MapGet("/orders/{id:int}", Results, NotFound> (int id) =>
{
    var order = orders.FirstOrDefault(o => o.Id == id);
    return order is not null
        ? TypedResults.Ok(order)
        : TypedResults.NotFound();
})
.WithName("GetOrderById")
.WithTags("Orders");

app.MapPost("/orders", Results, ValidationProblem> (CreateOrderRequest request) =>
{
    var order = new Order
    {
        Id = orders.Count + 1,
        CustomerId = request.CustomerId,
        Items = request.Items,
        CreatedAt = DateTime.UtcNow
    };

    orders.Add(order);
    return TypedResults.Created($"/orders/{order.Id}", order);
})
.WithName("CreateOrder")
.WithTags("Orders");

app.Run();

// Models
record Order
{
    public int Id { get; init; }
    public string CustomerId { get; init; } = "";
    public List Items { get; init; } = new();
    public DateTime CreatedAt { get; init; }
}

record CreateOrderRequest(string CustomerId, List Items);
Typed Results

Results<T1, T2> declares what your endpoint can return. This enables compile-time checking and accurate OpenAPI docs. Use TypedResults.Ok(), TypedResults.Created(), etc. instead of Results.Ok() for better tooling support.

Request/Response Binding

Minimal APIs bind request data automatically from route, query, header, and body. You control the source with attributes. Wrong binding causes subtle bugs.

Binding Sources

Use FromRoute for URL segments, FromQuery for query strings, FromBody for JSON payloads, FromHeader for headers. Primitives and simple types bind from route/query by default. Complex types bind from body.

Explicit Binding
app.MapGet("/orders/search", (
    [FromQuery] string? customerId,
    [FromQuery] int page = 1,
    [FromQuery] int pageSize = 10) =>
{
    var filtered = string.IsNullOrEmpty(customerId)
        ? orders
        : orders.Where(o => o.CustomerId == customerId);

    var paged = filtered
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToList();

    return TypedResults.Ok(new { Data = paged, Page = page, PageSize = pageSize });
})
.WithName("SearchOrders");

app.MapPut("/orders/{id:int}", (
    [FromRoute] int id,
    [FromBody] UpdateOrderRequest request) =>
{
    var order = orders.FirstOrDefault(o => o.Id == id);
    if (order is null) return TypedResults.NotFound();

    // Update logic here
    return TypedResults.NoContent();
});

ProblemDetails for Errors

Return RFC-7807 ProblemDetails for all errors. This gives clients a consistent error shape. ASP.NET Core generates it automatically for many scenarios, but you'll customize it for validation.

ProblemDetails Response
app.MapDelete("/orders/{id:int}", (int id) =>
{
    var order = orders.FirstOrDefault(o => o.Id == id);
    if (order is null)
    {
        return Results.Problem(
            title: "Order not found",
            detail: $"No order exists with ID {id}",
            statusCode: StatusCodes.Status404NotFound);
    }

    orders.Remove(order);
    return Results.NoContent();
});

The Problem() method returns a standardized error with type, title, status, detail, and instance fields. Clients can parse this reliably across all your endpoints.

Input Validation

Validation in Minimal APIs isn't automatic like MVC. You must wire it yourself. FluentValidation plus an endpoint filter gives you consistent validation across all endpoints.

FluentValidation Setup

Define validators for your request models. FluentValidation is more powerful than Data Annotations and easier to test.

FluentValidation Rules
using FluentValidation;

public class CreateOrderRequestValidator : AbstractValidator
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty()
            .WithMessage("CustomerId is required")
            .MaximumLength(50);

        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("Order must contain at least one item")
            .Must(items => items.Count <= 100)
            .WithMessage("Order cannot contain more than 100 items");
    }
}

// Register in DI
builder.Services.AddValidatorsFromAssemblyContaining();

Validation Filter

Create an endpoint filter that runs validation before your handler. If validation fails, return ValidationProblem with all errors. This centralizes validation logic.

Validation Endpoint Filter
using FluentValidation;

public class ValidationFilter : IEndpointFilter
{
    private readonly IValidator? _validator;

    public ValidationFilter(IServiceProvider serviceProvider)
    {
        _validator = serviceProvider.GetService>();
    }

    public async ValueTask InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        if (_validator is null)
        {
            return await next(context);
        }

        var request = context.Arguments
            .OfType()
            .FirstOrDefault();

        if (request is null)
        {
            return await next(context);
        }

        var validationResult = await _validator.ValidateAsync(request);

        if (!validationResult.IsValid)
        {
            var errors = validationResult.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(
                    g => g.Key,
                    g => g.Select(e => e.ErrorMessage).ToArray());

            return TypedResults.ValidationProblem(errors);
        }

        return await next(context);
    }
}

// Apply to endpoint
app.MapPost("/orders", (CreateOrderRequest request) =>
{
    // Validation already ran via filter
    var order = new Order { /* ... */ };
    orders.Add(order);
    return TypedResults.Created($"/orders/{order.Id}", order);
})
.AddEndpointFilter>();
Validation Best Practices

Always validate on the server, even if you validate on the client. Return consistent error shapes using ValidationProblem. Group errors by property name so clients can highlight specific fields. Set clear, actionable error messages.

Endpoint Filters

Endpoint filters wrap your handlers with cross-cutting logic. Timing, logging, auth checks, caching. They run before and after your handler. Chain multiple filters for complex pipelines.

Timing Filter for Performance

Log how long each request takes. Useful for finding slow endpoints in production.

Timing Filter
using System.Diagnostics;

public class TimingFilter : IEndpointFilter
{
    private readonly ILogger _logger;

    public TimingFilter(ILogger logger)
    {
        _logger = logger;
    }

    public async ValueTask InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var sw = Stopwatch.StartNew();
        var result = await next(context);
        sw.Stop();

        var endpoint = context.HttpContext.GetEndpoint()?.DisplayName ?? "Unknown";
        _logger.LogInformation(
            "Endpoint {Endpoint} completed in {ElapsedMs}ms",
            endpoint,
            sw.ElapsedMilliseconds);

        return result;
    }
}

// Apply globally to a route group
var apiGroup = app.MapGroup("/api")
    .AddEndpointFilter();

apiGroup.MapGet("/orders", () => { /* ... */ });

Authorization Pre-Check Filter

Check authorization conditions before hitting your handler. Short-circuit with 401 or 403 early.

Auth Pre-Check Filter
public class AuthPreCheckFilter : IEndpointFilter
{
    public async ValueTask InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var user = context.HttpContext.User;

        if (!user.Identity?.IsAuthenticated ?? true)
        {
            return TypedResults.Unauthorized();
        }

        // Additional claims check
        if (!user.HasClaim("scope", "orders.write"))
        {
            return TypedResults.Forbid();
        }

        return await next(context);
    }
}

Chain filters by calling AddEndpointFilter multiple times. They execute in order. Use route groups to apply filters to multiple endpoints at once.

API Versioning

APIs evolve. Versioning lets you change endpoints without breaking existing clients. Use URL-based versioning for simplicity. Header-based versioning for advanced scenarios.

URL Segment Versioning

Put the version in the URL: /v1/orders, /v2/orders. Clients see the version. It's explicit and easy to test.

API Versioning Setup
using Asp.Versioning;
using Asp.Versioning.Builder;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV";
    options.SubstituteApiVersionInUrl = true;
});

var app = builder.Build();

var v1 = app.NewVersionedApi()
    .MapGroup("/v1/orders")
    .HasApiVersion(1, 0);

var v2 = app.NewVersionedApi()
    .MapGroup("/v2/orders")
    .HasApiVersion(2, 0);

// V1 endpoints
v1.MapGet("/", () => TypedResults.Ok(orders))
    .WithName("GetOrders_V1");

v1.MapGet("/{id:int}", (int id) =>
{
    var order = orders.FirstOrDefault(o => o.Id == id);
    return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound();
})
.WithName("GetOrderById_V1");

// V2 endpoints with pagination (breaking change)
v2.MapGet("/", (int page = 1, int pageSize = 20) =>
{
    var paged = orders
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToList();

    return TypedResults.Ok(new
    {
        Data = paged,
        Page = page,
        PageSize = pageSize,
        TotalCount = orders.Count
    });
})
.WithName("GetOrders_V2");

app.Run();

Deprecation Headers

Mark old versions as deprecated. Add custom headers to warn clients.

Deprecation Filter
public class DeprecationFilter : IEndpointFilter
{
    public async ValueTask InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var result = await next(context);

        context.HttpContext.Response.Headers.Append("X-API-Deprecated", "true");
        context.HttpContext.Response.Headers.Append(
            "X-API-Sunset-Date",
            "2026-06-01");
        context.HttpContext.Response.Headers.Append(
            "Link",
            "; rel=\"successor-version\"");

        return result;
    }
}

// Apply to v1
v1.MapGroup("/orders")
    .AddEndpointFilter();
Versioning Strategy

Start with v1 even if you don't have v2 plans. Use URL versioning for simplicity. Reserve header versioning for scenarios where URL versioning doesn't work (like webhooks). Deprecate old versions with clear sunset dates.

Rate Limiting

Rate limiting protects your API from abuse. .NET 7+ has built-in rate limiting middleware. Configure policies per endpoint or globally.

Fixed Window Policy

Allow N requests per time window. Simple and effective for most APIs.

Rate Limiting Setup
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    // Per-IP policy for unauthenticated requests
    options.AddFixedWindowLimiter("fixed", opt =>
    {
        opt.PermitLimit = 100;
        opt.Window = TimeSpan.FromMinutes(1);
        opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
        opt.QueueLimit = 10;
    });

    // Per-user policy for authenticated requests
    options.AddTokenBucketLimiter("authenticated", opt =>
    {
        opt.TokenLimit = 1000;
        opt.TokensPerPeriod = 100;
        opt.ReplenishmentPeriod = TimeSpan.FromMinutes(1);
        opt.QueueLimit = 0;
    });

    // Tight policy for expensive operations
    options.AddSlidingWindowLimiter("expensive", opt =>
    {
        opt.PermitLimit = 10;
        opt.Window = TimeSpan.FromMinutes(1);
        opt.SegmentsPerWindow = 6;
    });

    options.OnRejected = async (context, cancellationToken) =>
    {
        context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;

        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            context.HttpContext.Response.Headers.RetryAfter = retryAfter.TotalSeconds.ToString();
        }

        await context.HttpContext.Response.WriteAsJsonAsync(
            new ProblemDetails
            {
                Status = StatusCodes.Status429TooManyRequests,
                Title = "Rate limit exceeded",
                Detail = "Too many requests. Please try again later."
            },
            cancellationToken);
    };
});

var app = builder.Build();

app.UseRateLimiter();

app.MapGet("/orders", () => TypedResults.Ok(orders))
    .RequireRateLimiting("fixed");

app.MapPost("/orders", (CreateOrderRequest request) =>
{
    // Create order logic
})
.RequireRateLimiting("authenticated");

Per-User Partitioning

Partition rate limits by user ID or API key. This prevents one user from exhausting limits for others.

Per-User Rate Limiting
builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("per-user", context =>
    {
        var userId = context.User.FindFirst("sub")?.Value ?? "anonymous";

        return RateLimitPartition.GetFixedWindowLimiter(userId, _ => new()
        {
            PermitLimit = 100,
            Window = TimeSpan.FromMinutes(1)
        });
    });
});

app.MapGet("/orders", () => TypedResults.Ok(orders))
    .RequireRateLimiting("per-user")
    .RequireAuthorization();

Use fixed window for general APIs. Token bucket for bursty traffic. Sliding window for precise control. Partition by user ID for fairness.

Authentication & Authorization

JWT bearer tokens are the standard for API authentication. Configure the middleware, define policies, and protect endpoints.

JWT Authentication Setup

Configure JWT validation parameters. Point to your identity provider's authority and audience.

JWT Configuration
using Microsoft.AspNetCore.Authentication.JwtBearer;

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Jwt:Authority"];
        options.Audience = builder.Configuration["Jwt:Audience"];
        options.TokenValidationParameters = new()
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ClockSkew = TimeSpan.FromMinutes(5)
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ReadOrders", policy =>
        policy.RequireClaim("scope", "orders.read"));

    options.AddPolicy("WriteOrders", policy =>
        policy.RequireClaim("scope", "orders.write"));

    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

Policy-Based Authorization

Use policies to encapsulate authorization logic. Check scopes, roles, or custom claims.

Protected Endpoints
app.MapGet("/orders", () => TypedResults.Ok(orders))
    .RequireAuthorization("ReadOrders");

app.MapPost("/orders", (CreateOrderRequest request) =>
{
    // Create order logic
})
.RequireAuthorization("WriteOrders");

app.MapDelete("/orders/{id:int}", (int id) =>
{
    // Delete order logic
})
.RequireAuthorization("AdminOnly");

// Custom authorization with IAuthorizationService
app.MapGet("/orders/{id:int}", async (
    int id,
    IAuthorizationService authService,
    ClaimsPrincipal user) =>
{
    var order = orders.FirstOrDefault(o => o.Id == id);
    if (order is null) return Results.NotFound();

    // Check if user owns this order
    var authResult = await authService.AuthorizeAsync(
        user,
        order,
        "OwnerOnly");

    if (!authResult.Succeeded)
    {
        return Results.Forbid();
    }

    return TypedResults.Ok(order);
});
Auth Best Practices

Use policy-based authorization instead of checking claims manually. Always validate tokens on the server. Use short token lifetimes with refresh tokens. Log authorization failures for security monitoring. Test negative cases where users shouldn't have access.

OpenAPI & Swagger

OpenAPI documentation makes your API discoverable. Swagger UI provides an interactive test interface. Generate accurate docs with minimal extra code.

OpenAPI Configuration

Configure Swagger with versioned docs, examples, and proper schemas.

OpenAPI Setup
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new()
    {
        Title = "Orders API",
        Version = "v1",
        Description = "Orders management API - Version 1"
    });

    options.SwaggerDoc("v2", new()
    {
        Title = "Orders API",
        Version = "v2",
        Description = "Orders management API - Version 2 with pagination"
    });

    options.AddSecurityDefinition("Bearer", new()
    {
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT",
        Description = "Enter JWT token"
    });

    options.AddSecurityRequirement(new()
    {
        {
            new()
            {
                Reference = new() { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
            },
            Array.Empty()
        }
    });
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/swagger/v1/swagger.json", "Orders API V1");
        options.SwaggerEndpoint("/swagger/v2/swagger.json", "Orders API V2");
    });
}

Endpoint Metadata

Use WithOpenApi() to add descriptions, examples, and response types.

OpenAPI Metadata
app.MapPost("/orders", (CreateOrderRequest request) =>
{
    // Handler logic
})
.WithOpenApi(operation =>
{
    operation.Summary = "Create a new order";
    operation.Description = "Creates a new order for the authenticated customer";
    operation.Parameters[0].Description = "Order creation request with customer ID and items";
    return operation;
})
.Produces(StatusCodes.Status201Created)
.ProducesValidationProblem()
.WithTags("Orders");

Group endpoints by tags. Document all possible responses. Include examples for complex types. Version your docs alongside your API.

Performance & Caching

Caching reduces database load and speeds up responses. Use response caching for static data. Output caching for personalized data. ETags for conditional requests.

Response Caching

Cache entire responses for GET requests. Works with HTTP cache headers.

Response Caching
builder.Services.AddResponseCaching();

var app = builder.Build();

app.UseResponseCaching();

app.MapGet("/orders", () => TypedResults.Ok(orders))
    .CacheOutput(policy => policy
        .Expire(TimeSpan.FromMinutes(5))
        .SetVaryByQuery("page", "pageSize"));

Output Caching

Output caching is more flexible than response caching. Cache on the server. Vary by user, query, or header.

Output Caching
builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("OrdersList", builder => builder
        .Expire(TimeSpan.FromMinutes(5))
        .SetVaryByQuery("page", "customerId")
        .Tag("orders"));

    options.AddPolicy("OrderDetail", builder => builder
        .Expire(TimeSpan.FromMinutes(10))
        .SetVaryByRouteValue("id")
        .Tag("orders"));
});

var app = builder.Build();

app.UseOutputCache();

app.MapGet("/orders", () => TypedResults.Ok(orders))
    .CacheOutput("OrdersList");

app.MapGet("/orders/{id:int}", (int id) =>
{
    var order = orders.FirstOrDefault(o => o.Id == id);
    return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound();
})
.CacheOutput("OrderDetail");

// Invalidate cache on mutations
app.MapPost("/orders", async (CreateOrderRequest request, IOutputCacheStore cache) =>
{
    // Create order
    await cache.EvictByTagAsync("orders", default);
    return TypedResults.Created($"/orders/1", new Order());
});
Caching Strategy

Use response caching for public, static data. Output caching for user-specific data. ETags for large responses. Invalidate caches on writes. Set appropriate expiration times based on data staleness tolerance.

Observability

Production APIs need logging, metrics, and tracing. Use structured logging for searchability. OpenTelemetry for distributed tracing. Health checks for monitoring.

Structured Logging

Log with structured data. Include order IDs, user IDs, and correlation IDs.

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

    try
    {
        var order = new Order { /* ... */ };
        orders.Add(order);

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

        return TypedResults.Created($"/orders/{order.Id}", order);
    }
    catch (Exception ex)
    {
        logger.LogError(
            ex,
            "Failed to create order for customer {CustomerId}",
            request.CustomerId);
        throw;
    }
});

OpenTelemetry Tracing

Add OpenTelemetry for distributed tracing across services.

OpenTelemetry Setup
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing
            .SetResourceBuilder(ResourceBuilder.CreateDefault()
                .AddService("OrdersApi"))
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddConsoleExporter(); // Use OTLP exporter in production
    });

// Custom activity source for business logic
var activitySource = new ActivitySource("OrdersApi.Orders");

app.MapPost("/orders", (CreateOrderRequest request) =>
{
    using var activity = activitySource.StartActivity("CreateOrder");
    activity?.SetTag("customer.id", request.CustomerId);
    activity?.SetTag("items.count", request.Items.Count);

    // Order creation logic

    return TypedResults.Created($"/orders/1", new Order());
});

Add health checks for dependencies. Expose metrics via Prometheus. Use correlation IDs to trace requests across services.

End-to-End Scenario Build

Let's combine everything into a complete Orders API. All patterns working together.

Complete Program.cs
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using System.Threading.RateLimiting;
using Asp.Versioning;

var builder = WebApplication.CreateBuilder(args);

// Services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddValidatorsFromAssemblyContaining();
builder.Services.AddOutputCache();

// Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer();

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ReadOrders", p => p.RequireClaim("scope", "orders.read"));
    options.AddPolicy("WriteOrders", p => p.RequireClaim("scope", "orders.write"));
});

// API Versioning
builder.Services.AddApiVersioning(opt =>
{
    opt.DefaultApiVersion = new ApiVersion(1, 0);
    opt.AssumeDefaultVersionWhenUnspecified = true;
    opt.ReportApiVersions = true;
    opt.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(opt =>
{
    opt.GroupNameFormat = "'v'VVV";
    opt.SubstituteApiVersionInUrl = true;
});

// Rate Limiting
builder.Services.AddRateLimiter(opt =>
{
    opt.AddPolicy("per-user", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            context.User.FindFirst("sub")?.Value ?? "anon",
            _ => new() { PermitLimit = 100, Window = TimeSpan.FromMinutes(1) }));
});

var app = builder.Build();

// Middleware
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseOutputCache();

// In-memory storage
var orders = new List();

// V1 API
var v1 = app.NewVersionedApi()
    .MapGroup("/v1/orders")
    .HasApiVersion(1, 0)
    .AddEndpointFilter()
    .WithTags("Orders V1");

v1.MapGet("/", () => TypedResults.Ok(orders))
    .RequireAuthorization("ReadOrders")
    .RequireRateLimiting("per-user")
    .CacheOutput();

v1.MapGet("/{id:int}", (int id) =>
{
    var order = orders.FirstOrDefault(o => o.Id == id);
    return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound();
})
.RequireAuthorization("ReadOrders");

v1.MapPost("/", (CreateOrderRequest request) =>
{
    var order = new Order
    {
        Id = orders.Count + 1,
        CustomerId = request.CustomerId,
        Items = request.Items,
        CreatedAt = DateTime.UtcNow
    };
    orders.Add(order);
    return TypedResults.Created($"/v1/orders/{order.Id}", order);
})
.RequireAuthorization("WriteOrders")
.AddEndpointFilter>();

// V2 API with pagination
var v2 = app.NewVersionedApi()
    .MapGroup("/v2/orders")
    .HasApiVersion(2, 0)
    .AddEndpointFilter()
    .WithTags("Orders V2");

v2.MapGet("/", (int page = 1, int pageSize = 20) =>
{
    var paged = orders
        .Skip((page - 1) * pageSize)
        .Take(pageSize);

    return TypedResults.Ok(new
    {
        Data = paged,
        Page = page,
        PageSize = pageSize,
        TotalCount = orders.Count
    });
})
.RequireAuthorization("ReadOrders")
.CacheOutput(p => p.SetVaryByQuery("page", "pageSize"));

app.Run();

// Models
record Order
{
    public int Id { get; init; }
    public string CustomerId { get; init; } = "";
    public List Items { get; init; } = new();
    public DateTime CreatedAt { get; init; }
}

record CreateOrderRequest(string CustomerId, List Items);

// Validators
public class CreateOrderRequestValidator : AbstractValidator
{
    public CreateOrderRequestValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty().MaximumLength(50);
        RuleFor(x => x.Items).NotEmpty().Must(i => i.Count <= 100);
    }
}

Integration Test

Test your API with WebApplicationFactory. Verify filters, validation, and versioning work correctly.

Integration Test
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net;
using System.Net.Http.Json;
using Xunit;

public class OrdersApiTests : IClassFixture>
{
    private readonly HttpClient _client;

    public OrdersApiTests(WebApplicationFactory factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task CreateOrder_WithValidRequest_ReturnsCreated()
    {
        var request = new CreateOrderRequest("customer-123", new() { "item1", "item2" });

        var response = await _client.PostAsJsonAsync("/v1/orders", request);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        var order = await response.Content.ReadFromJsonAsync();
        Assert.NotNull(order);
        Assert.Equal("customer-123", order.CustomerId);
    }

    [Fact]
    public async Task CreateOrder_WithInvalidRequest_ReturnsValidationProblem()
    {
        var request = new CreateOrderRequest("", new());

        var response = await _client.PostAsJsonAsync("/v1/orders", request);

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }

    [Fact]
    public async Task GetOrders_V2_ReturnsPaginatedResult()
    {
        var response = await _client.GetAsync("/v2/orders?page=1&pageSize=10");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var result = await response.Content.ReadFromJsonAsync();
        Assert.NotNull(result);
        Assert.Equal(1, result.Page);
        Assert.Equal(10, result.PageSize);
    }
}

Deployment Tips

Production deployment requires careful configuration. Kestrel settings, HTTP/2, containers, and environment-based config.

Kestrel Configuration

Configure Kestrel for production workloads. Enable HTTP/2, set limits, and tune performance.

appsettings.Production.json
{
  "Kestrel": {
    "Limits": {
      "MaxConcurrentConnections": 100,
      "MaxRequestBodySize": 10485760,
      "KeepAliveTimeout": "00:02:00"
    },
    "EndpointDefaults": {
      "Protocols": "Http1AndHttp2"
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  }
}

Docker Configuration

Use multi-stage builds for small images. Run as non-root. Enable health checks.

Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["OrdersApi.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .

# Create non-root user
RUN useradd -m -s /bin/bash apiuser && chown -R apiuser:apiuser /app
USER apiuser

EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1

ENTRYPOINT ["dotnet", "OrdersApi.dll"]

Use environment variables for secrets. Enable HTTPS in production. Configure CORS for cross-origin requests. Monitor with health checks and metrics endpoints.

Resources & Next Steps

You've built a production-ready Minimal API with all the patterns that matter. Here's where to go deeper.

Official Documentation

Next Steps

Add persistence with Entity Framework Core or Dapper. Implement CQRS with MediatR. Add real-time features with SignalR. Build a gateway with YARP reverse proxy. Deploy to Kubernetes with proper health checks and observability.

The patterns you learned here scale from small microservices to high-traffic APIs. Start simple. Add complexity only when you need it.

Frequently Asked Questions

When should I choose Minimal APIs over MVC controllers?

Choose Minimal APIs for microservices, new APIs, and projects that value performance and simplicity. They have less overhead, faster startup, and cleaner code for simple endpoints. Stick with MVC controllers if you need complex model binding, action filters across many endpoints, or have an existing MVC codebase. Minimal APIs excel at focused, high-performance scenarios.

How do I share filters across multiple endpoints?

Create a route group with AddEndpointFilterFactory or AddEndpointFilter, then map endpoints to that group. Filters applied to the group run for all endpoints in it. You can also chain filters on individual endpoints. For global filters, use middleware instead.

Can I use Data Annotations instead of FluentValidation?

Yes, but you'll need to trigger validation manually with Validator.TryValidateObject. Minimal APIs don't validate Data Annotations automatically like MVC does. FluentValidation integrates cleanly with endpoint filters and gives you more control over validation logic and error responses.

How does rate limiting affect legitimate users?

With proper partitioning, rate limiting protects legitimate users from abuse. Use per-user partitioning for authenticated requests so one user can't exhaust limits for others. Set generous limits for normal usage and tight limits for expensive operations. Return clear 429 responses with Retry-After headers so clients can back off gracefully.

Should I version my API from the start?

Yes, if you're building a public API or one consumed by multiple clients. Start with v1 even if you have no v2 plans. This makes future changes non-breaking. For internal APIs with tight coupling, you might skip versioning initially. But adding it later requires client coordination, so err on the side of versioning early.

Back to Tutorials