ASP.NET Core / Minimal APIs in the Real World: Filters, Validation, Versioning & Rate Limiting
20 min read
Intermediate
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
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
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
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
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.
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.
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.
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.
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.