
Minimal Apis
- 35 installs
- 466 repo stars
- Updated July 25, 2026
- managedcode/dotnet-skills
Helps with backend & apis tasks.
About
minimal-apis is a Claude Code skill for backend & apis. It helps solo builders move faster with AI-assisted coding.
- minimal-apis
- Backend & APIs
- AI-coding skill
Minimal Apis by the numbers
- 35 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,328 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/managedcode/dotnet-skills --skill minimal-apisAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 35 |
|---|---|
| repo stars | ★ 466 |
| Last updated | July 25, 2026 |
| Repository | managedcode/dotnet-skills ↗ |
What it does
Helps with backend & apis tasks.
Files
Minimal APIs
Trigger On
- building new HTTP APIs in ASP.NET Core
- creating lightweight microservices
- choosing between Minimal APIs and controllers
- organizing endpoints with route groups
- implementing validation and filters
Documentation
References
- patterns.md - detailed route groups, filters, TypedResults patterns, parameter binding, error handling, and testing
- anti-patterns.md - common Minimal API mistakes to avoid
When to Use Minimal APIs vs Controllers
| Use Minimal APIs | Use Controllers |
|---|---|
| New projects | Existing MVC/API projects |
| Microservices | Complex model binding |
| Simple CRUD APIs | OData, JsonPatch |
| Lightweight handlers | Heavy use of attributes |
| .NET 8+ projects | Need [ApiController] features |
Workflow
1. Define endpoints directly in Program.cs (for small APIs) 2. Use route groups for related endpoints 3. Move handlers to separate classes as the API grows 4. Apply filters for cross-cutting concerns 5. Use TypedResults for type-safe responses 6. Generate OpenAPI docs with .WithOpenApi()
Current Upstream Notes
dotnet/aspnetcorev9.0.17is a servicing release; it does not change the Minimal API route-group/filter/TypedResults model.- Continue to use the
aspnetcore-10.0Learn overview and Minimal API pages when exact OpenAPI, filter, or parameter-binding behavior matters.
Basic Patterns
Simple Endpoints
var app = builder.Build();
app.MapGet("/", () => "Hello World");
app.MapGet("/products/{id}", (int id) => Results.Ok(new { Id = id }));
app.MapPost("/products", (Product product) => Results.Created($"/products/{product.Id}", product));TypedResults (Strongly-Typed)
app.MapGet("/products/{id}", Results<Ok<Product>, NotFound> (int id, AppDb db) =>
{
var product = db.Products.Find(id);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
});Dependency Injection
app.MapGet("/products", async (IProductService service) =>
{
return await service.GetAllAsync();
});
// Or with [FromServices] for clarity
app.MapGet("/products", async ([FromServices] IProductService service) =>
await service.GetAllAsync());Route Groups
Basic Grouping
var products = app.MapGroup("/api/products");
products.MapGet("/", GetAll);
products.MapGet("/{id}", GetById);
products.MapPost("/", Create);
products.MapPut("/{id}", Update);
products.MapDelete("/{id}", Delete);Groups with Shared Configuration
var api = app.MapGroup("/api")
.RequireAuthorization()
.AddEndpointFilter<ValidationFilter>();
var products = api.MapGroup("/products")
.WithTags("Products");
var orders = api.MapGroup("/orders")
.WithTags("Orders")
.RequireAuthorization("AdminOnly");Endpoint Filters
Inline Filter
app.MapGet("/products/{id}", (int id) => Results.Ok(id))
.AddEndpointFilter(async (context, next) =>
{
var id = context.GetArgument<int>(0);
if (id <= 0)
return Results.BadRequest("Invalid ID");
return await next(context);
});Class-Based Filter
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (argument is null)
return Results.BadRequest("Invalid request body");
var validator = context.HttpContext.RequestServices
.GetService<IValidator<T>>();
if (validator is not null)
{
var result = await validator.ValidateAsync(argument);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
}
return await next(context);
}
}
// Usage
products.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();Global Filters via Root Group
// All endpoints inherit filters from root group
var root = app.MapGroup("")
.AddEndpointFilter<LoggingFilter>()
.AddEndpointFilter<ErrorHandlingFilter>();
root.MapGet("/health", () => Results.Ok());
root.MapGroup("/api/products").MapGet("/", GetProducts);Organizing Larger APIs
Extension Method Pattern
// ProductEndpoints.cs
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/products")
.WithTags("Products");
group.MapGet("/", GetAll);
group.MapGet("/{id}", GetById);
group.MapPost("/", Create);
return group;
}
private static async Task<Ok<List<Product>>> GetAll(IProductService service)
=> TypedResults.Ok(await service.GetAllAsync());
private static async Task<Results<Ok<Product>, NotFound>> GetById(
int id, IProductService service)
{
var product = await service.GetByIdAsync(id);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
}
private static async Task<Created<Product>> Create(
CreateProductRequest request, IProductService service)
{
var product = await service.CreateAsync(request);
return TypedResults.Created($"/api/products/{product.Id}", product);
}
}
// Program.cs
app.MapProductEndpoints();
app.MapOrderEndpoints();Request/Response DTOs
// Separate from domain models
public record CreateProductRequest(string Name, decimal Price);
public record UpdateProductRequest(string Name, decimal Price);
public record ProductResponse(int Id, string Name, decimal Price);
// Don't expose domain entities directly
app.MapPost("/products", (CreateProductRequest request, IMapper mapper) =>
{
var product = mapper.Map<Product>(request);
// ...
return TypedResults.Created($"/products/{product.Id}",
mapper.Map<ProductResponse>(product));
});Anti-Patterns to Avoid
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Everything in Program.cs | Unmaintainable | Use extension methods |
| No route groups | Repetitive config | Group related endpoints |
| Manual validation | Error-prone | Use filters + FluentValidation |
| Exposing entities | Tight coupling | Use DTOs |
| No TypedResults | No compile-time checks | Use TypedResults |
| Ignoring OpenAPI | No documentation | Add .WithOpenApi() |
OpenAPI Integration
builder.Services.AddOpenApi();
app.MapOpenApi(); // Serves OpenAPI spec
app.MapGet("/products", GetProducts)
.WithName("GetProducts")
.WithSummary("Get all products")
.WithDescription("Returns a list of all available products")
.Produces<List<Product>>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status500InternalServerError);Deliver
- clean, organized Minimal API endpoints
- proper use of route groups and filters
- type-safe responses with TypedResults
- OpenAPI documentation
- validation with endpoint filters
Validate
- endpoints return correct status codes
- validation filters catch invalid input
- OpenAPI spec is accurate
- route groups share common configuration
- handlers are testable (can mock dependencies)
{
"version": "1.0.1",
"category": "Web"
}
Minimal API Anti-Patterns
Structural Anti-Patterns
Monolithic Program.cs
Problem: All endpoints defined directly in Program.cs becomes unmaintainable.
// BAD: Everything in Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/products", async (AppDb db) => await db.Products.ToListAsync());
app.MapGet("/products/{id}", async (int id, AppDb db) => await db.Products.FindAsync(id));
app.MapPost("/products", async (Product p, AppDb db) => { db.Add(p); await db.SaveChangesAsync(); return p; });
// ... 50 more endpoints
app.MapGet("/orders", async (AppDb db) => await db.Orders.ToListAsync());
// ... 50 more endpoints
app.MapGet("/customers", async (AppDb db) => await db.Customers.ToListAsync());
// ... and so on
app.Run();Solution: Use extension methods to organize endpoints by domain.
// GOOD: Organized via extension methods
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapCustomerEndpoints();Flat Route Structure Without Groups
Problem: Repeating configuration across related endpoints.
// BAD: No route groups, repetitive configuration
app.MapGet("/api/products", GetProducts)
.RequireAuthorization()
.WithTags("Products");
app.MapGet("/api/products/{id}", GetProductById)
.RequireAuthorization()
.WithTags("Products");
app.MapPost("/api/products", CreateProduct)
.RequireAuthorization()
.WithTags("Products")
.AddEndpointFilter<ValidationFilter>();Solution: Use route groups to share configuration.
// GOOD: Shared configuration via groups
var products = app.MapGroup("/api/products")
.RequireAuthorization()
.WithTags("Products");
products.MapGet("/", GetProducts);
products.MapGet("/{id}", GetProductById);
products.MapPost("/", CreateProduct).AddEndpointFilter<ValidationFilter>();Handler Anti-Patterns
Anonymous Lambda Handlers
Problem: Complex inline lambdas are hard to test and maintain.
// BAD: Complex inline logic
app.MapPost("/products", async (CreateProductRequest request, AppDb db, IMapper mapper) =>
{
if (string.IsNullOrEmpty(request.Name))
return Results.BadRequest("Name required");
if (request.Price <= 0)
return Results.BadRequest("Price must be positive");
if (await db.Products.AnyAsync(p => p.Sku == request.Sku))
return Results.Conflict("SKU exists");
var product = mapper.Map<Product>(request);
product.CreatedAt = DateTime.UtcNow;
db.Products.Add(product);
await db.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", mapper.Map<ProductResponse>(product));
});Solution: Extract to named methods or service classes.
// GOOD: Extracted handler with validation filter
products.MapPost("/", CreateProduct)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
private static async Task<Results<Created<ProductResponse>, Conflict<string>>> CreateProduct(
CreateProductRequest request,
IProductService service)
{
var result = await service.CreateAsync(request);
return result.Match<Results<Created<ProductResponse>, Conflict<string>>>(
success => TypedResults.Created($"/products/{success.Id}", success),
conflict => TypedResults.Conflict(conflict.Message));
}Mixing Concerns in Handlers
Problem: Handlers doing validation, mapping, business logic, and persistence.
// BAD: Handler does everything
app.MapPut("/products/{id}", async (int id, UpdateProductRequest request, AppDb db) =>
{
// Validation
if (request.Price < 0) return Results.BadRequest("Invalid price");
// Fetch
var product = await db.Products.FindAsync(id);
if (product is null) return Results.NotFound();
// Authorization
if (product.OwnerId != GetCurrentUserId()) return Results.Forbid();
// Mapping
product.Name = request.Name;
product.Price = request.Price;
product.UpdatedAt = DateTime.UtcNow;
// Persistence
await db.SaveChangesAsync();
// Response mapping
return Results.Ok(new ProductResponse(product.Id, product.Name, product.Price));
});Solution: Separate concerns using filters and services.
// GOOD: Concerns separated
products.MapPut("/{id}", UpdateProduct)
.AddEndpointFilter<ValidationFilter<UpdateProductRequest>>()
.AddEndpointFilter<ProductOwnershipFilter>();
private static async Task<Results<Ok<ProductResponse>, NotFound>> UpdateProduct(
int id,
UpdateProductRequest request,
IProductService service)
{
var result = await service.UpdateAsync(id, request);
return result.Match<Results<Ok<ProductResponse>, NotFound>>(
success => TypedResults.Ok(success),
_ => TypedResults.NotFound());
}Response Anti-Patterns
Using Results Instead of TypedResults
Problem: Results factory methods lose compile-time type checking.
// BAD: No compile-time checking of response types
app.MapGet("/products/{id}", async (int id, AppDb db) =>
{
var product = await db.Products.FindAsync(id);
return product is not null
? Results.Ok(product) // IResult, no type info
: Results.NotFound(); // IResult, no type info
});Solution: Use TypedResults with union return types.
// GOOD: Compile-time checked response types
app.MapGet("/products/{id}", async Task<Results<Ok<ProductDto>, NotFound>> (int id, AppDb db) =>
{
var product = await db.Products.FindAsync(id);
return product is not null
? TypedResults.Ok(product.ToDto())
: TypedResults.NotFound();
});Exposing Domain Entities
Problem: Returning EF Core entities exposes internals and causes serialization issues.
// BAD: Exposing domain entity
app.MapGet("/products/{id}", async (int id, AppDb db) =>
{
return await db.Products
.Include(p => p.Category)
.Include(p => p.Supplier)
.FirstOrDefaultAsync(p => p.Id == id);
// Leaks navigation properties, internal IDs, circular references
});Solution: Use DTOs/response records.
// GOOD: Return DTO
app.MapGet("/products/{id}", async Task<Results<Ok<ProductResponse>, NotFound>>
(int id, IProductService service) =>
{
var dto = await service.GetByIdAsync(id);
return dto is not null
? TypedResults.Ok(dto)
: TypedResults.NotFound();
});
public record ProductResponse(
int Id,
string Name,
decimal Price,
string CategoryName,
bool InStock);Inconsistent Status Codes
Problem: Endpoints return inconsistent status codes for similar operations.
// BAD: Inconsistent responses
app.MapPost("/products", (Product p, AppDb db) =>
{
db.Add(p);
db.SaveChanges();
return Results.Ok(p); // Should be 201 Created
});
app.MapPost("/orders", (Order o, AppDb db) =>
{
db.Add(o);
db.SaveChanges();
return Results.Json(o, statusCode: 200); // Also wrong
});Solution: Follow REST conventions consistently.
// GOOD: Consistent REST responses
app.MapPost("/products", async Task<Created<ProductResponse>>
(CreateProductRequest request, IProductService service) =>
{
var product = await service.CreateAsync(request);
return TypedResults.Created($"/products/{product.Id}", product);
});Validation Anti-Patterns
Inline Validation in Handlers
Problem: Validation logic scattered across handlers, hard to maintain and test.
// BAD: Manual validation in handler
app.MapPost("/products", (CreateProductRequest request, AppDb db) =>
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(request.Name))
errors.Add("Name is required");
if (request.Name?.Length > 100)
errors.Add("Name must be 100 characters or less");
if (request.Price <= 0)
errors.Add("Price must be positive");
if (string.IsNullOrWhiteSpace(request.Sku))
errors.Add("SKU is required");
if (!Regex.IsMatch(request.Sku ?? "", @"^[A-Z]{3}-\d{4}$"))
errors.Add("SKU must match format XXX-0000");
if (errors.Any())
return Results.BadRequest(new { Errors = errors });
// ... create product
});Solution: Use validation filters with FluentValidation.
// GOOD: Declarative validation
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.Price)
.GreaterThan(0);
RuleFor(x => x.Sku)
.NotEmpty()
.Matches(@"^[A-Z]{3}-\d{4}$")
.WithMessage("SKU must match format XXX-0000");
}
}
products.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();Not Returning Problem Details
Problem: Custom error formats break client expectations.
// BAD: Non-standard error format
return Results.BadRequest(new
{
success = false,
error_code = "VALIDATION_ERROR",
messages = errors
});Solution: Use RFC 7807 Problem Details.
// GOOD: Standard Problem Details
return TypedResults.ValidationProblem(
errors.ToDictionary(e => e.PropertyName, e => new[] { e.ErrorMessage }),
title: "Validation failed",
detail: "One or more validation errors occurred");Dependency Injection Anti-Patterns
Service Locator Pattern
Problem: Using HttpContext.RequestServices directly hides dependencies.
// BAD: Service locator
app.MapGet("/products", (HttpContext context) =>
{
var db = context.RequestServices.GetRequiredService<AppDb>();
var logger = context.RequestServices.GetRequiredService<ILogger>();
var cache = context.RequestServices.GetRequiredService<IDistributedCache>();
// ... use services
});Solution: Declare dependencies as parameters.
// GOOD: Explicit dependencies
app.MapGet("/products", async (
AppDb db,
ILogger<ProductsEndpoints> logger,
IDistributedCache cache) =>
{
// Dependencies are clear and testable
});Over-Injection
Problem: Too many parameters indicate the handler does too much.
// BAD: Too many dependencies
app.MapPost("/orders", async (
CreateOrderRequest request,
AppDb db,
IMapper mapper,
IInventoryService inventory,
IPaymentService payment,
IShippingService shipping,
INotificationService notifications,
ILogger<OrdersEndpoints> logger,
IDistributedCache cache) =>
{
// This handler orchestrates too much
});Solution: Introduce a service to coordinate the operation.
// GOOD: Single coordinating service
app.MapPost("/orders", async Task<Results<Created<OrderResponse>, BadRequest<ProblemDetails>>>
(CreateOrderRequest request, IOrderService orderService) =>
{
var result = await orderService.CreateAsync(request);
return result.ToHttpResult();
});OpenAPI Anti-Patterns
Missing Response Documentation
Problem: Endpoints without OpenAPI metadata have incomplete specs.
// BAD: No OpenAPI metadata
app.MapGet("/products/{id}", GetProductById);Solution: Add comprehensive OpenAPI metadata.
// GOOD: Complete OpenAPI metadata
app.MapGet("/products/{id}", GetProductById)
.WithName("GetProductById")
.WithSummary("Get a product by ID")
.Produces<ProductResponse>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status404NotFound)
.WithOpenApi();Inconsistent Naming
Problem: Endpoint names don't follow conventions.
// BAD: Inconsistent naming
app.MapGet("/products", GetAll).WithName("products_list");
app.MapGet("/products/{id}", GetById).WithName("GetProduct");
app.MapPost("/products", Create).WithName("create-product");Solution: Follow consistent naming convention.
// GOOD: Consistent PascalCase operation IDs
app.MapGet("/products", GetAll).WithName("GetProducts");
app.MapGet("/products/{id}", GetById).WithName("GetProductById");
app.MapPost("/products", Create).WithName("CreateProduct");Security Anti-Patterns
Missing Authorization
Problem: Forgetting to protect sensitive endpoints.
// BAD: No authorization
app.MapDelete("/products/{id}", DeleteProduct);
app.MapGet("/admin/users", GetAllUsers);Solution: Apply authorization at group level or per-endpoint.
// GOOD: Authorization applied
var api = app.MapGroup("/api")
.RequireAuthorization();
var admin = app.MapGroup("/admin")
.RequireAuthorization("AdminOnly");Logging Sensitive Data
Problem: Logging request bodies or headers that contain secrets.
// BAD: Logs sensitive data
app.MapPost("/auth/login", async (LoginRequest request, ILogger logger) =>
{
logger.LogInformation("Login attempt: {@Request}", request);
// Logs password!
});Solution: Exclude sensitive fields from logging.
// GOOD: Exclude sensitive data
public record LoginRequest(
string Username,
[property: JsonIgnore] string Password);
// Or use destructuring carefully
logger.LogInformation("Login attempt for user {Username}", request.Username);Performance Anti-Patterns
N+1 Queries
Problem: Lazy loading causes multiple database roundtrips.
// BAD: N+1 queries
app.MapGet("/orders", async (AppDb db) =>
{
var orders = await db.Orders.ToListAsync();
return orders.Select(o => new OrderResponse(
o.Id,
o.Customer.Name, // N additional queries!
o.Items.Count // N additional queries!
));
});Solution: Eager load required data.
// GOOD: Single query with includes
app.MapGet("/orders", async (AppDb db) =>
{
var orders = await db.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.Select(o => new OrderResponse(
o.Id,
o.Customer.Name,
o.Items.Count))
.ToListAsync();
return TypedResults.Ok(orders);
});No Cancellation Token Support
Problem: Long-running operations don't respect client disconnection.
// BAD: No cancellation
app.MapGet("/reports/generate", async (IReportService service) =>
{
var report = await service.GenerateLargeReportAsync();
// Continues even if client disconnects
return Results.Ok(report);
});Solution: Accept and pass cancellation tokens.
// GOOD: Cancellation token support
app.MapGet("/reports/generate", async (
IReportService service,
CancellationToken cancellationToken) =>
{
var report = await service.GenerateLargeReportAsync(cancellationToken);
return TypedResults.Ok(report);
});Blocking Calls
Problem: Synchronous operations block the thread pool.
// BAD: Blocking calls
app.MapGet("/products", (AppDb db) =>
{
var products = db.Products.ToList(); // Synchronous!
Thread.Sleep(1000); // Blocking!
return Results.Ok(products);
});Solution: Use async operations throughout.
// GOOD: Fully async
app.MapGet("/products", async (AppDb db) =>
{
await Task.Delay(1000); // If delay needed
var products = await db.Products.ToListAsync();
return TypedResults.Ok(products);
});Minimal API Patterns
Route Groups
Hierarchical Route Groups
Build nested groups for complex API structures:
var api = app.MapGroup("/api/v1")
.RequireAuthorization();
var products = api.MapGroup("/products")
.WithTags("Products");
var productReviews = products.MapGroup("/{productId:int}/reviews")
.WithTags("Product Reviews");
productReviews.MapGet("/", GetReviewsForProduct);
productReviews.MapPost("/", AddReviewToProduct);
productReviews.MapGet("/{reviewId:int}", GetReviewById);Group with Parameter Validation
Apply route constraints at the group level:
var products = app.MapGroup("/api/products/{productId:int:min(1)}")
.AddEndpointFilter(async (context, next) =>
{
var productId = context.GetArgument<int>(0);
var db = context.HttpContext.RequestServices.GetRequiredService<AppDb>();
if (!await db.Products.AnyAsync(p => p.Id == productId))
return TypedResults.NotFound();
return await next(context);
});
products.MapGet("/", (int productId) => ...);
products.MapGet("/variants", (int productId) => ...);Versioned API Groups
var v1 = app.MapGroup("/api/v1").WithGroupName("v1");
var v2 = app.MapGroup("/api/v2").WithGroupName("v2");
v1.MapGet("/products", GetProductsV1);
v2.MapGet("/products", GetProductsV2);Endpoint Filters
Validation Filter with FluentValidation
public class FluentValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null)
return TypedResults.BadRequest(new ProblemDetails
{
Title = "Missing request body",
Status = StatusCodes.Status400BadRequest
});
var validator = context.HttpContext.RequestServices
.GetService<IValidator<T>>();
if (validator is not null)
{
var result = await validator.ValidateAsync(argument);
if (!result.IsValid)
{
return TypedResults.ValidationProblem(
result.ToDictionary(),
title: "Validation failed");
}
}
return await next(context);
}
}Logging Filter
public class RequestLoggingFilter(ILogger<RequestLoggingFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var sw = Stopwatch.StartNew();
var path = context.HttpContext.Request.Path;
var method = context.HttpContext.Request.Method;
logger.LogInformation("Request {Method} {Path} started", method, path);
try
{
var result = await next(context);
sw.Stop();
logger.LogInformation(
"Request {Method} {Path} completed in {ElapsedMs}ms",
method, path, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
sw.Stop();
logger.LogError(ex,
"Request {Method} {Path} failed after {ElapsedMs}ms",
method, path, sw.ElapsedMilliseconds);
throw;
}
}
}Rate Limiting Filter
public class RateLimitingFilter(IRateLimiter limiter) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var clientId = context.HttpContext.User.FindFirst("sub")?.Value
?? context.HttpContext.Connection.RemoteIpAddress?.ToString()
?? "unknown";
if (!await limiter.TryAcquireAsync(clientId))
{
return TypedResults.StatusCode(StatusCodes.Status429TooManyRequests);
}
return await next(context);
}
}Idempotency Filter
public class IdempotencyFilter(IDistributedCache cache) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var idempotencyKey = context.HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(idempotencyKey))
return await next(context);
var cacheKey = $"idempotency:{idempotencyKey}";
var cachedResponse = await cache.GetStringAsync(cacheKey);
if (cachedResponse is not null)
return TypedResults.Content(cachedResponse, "application/json");
var result = await next(context);
if (result is IValueHttpResult httpResult)
{
var json = JsonSerializer.Serialize(httpResult.Value);
await cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
});
}
return result;
}
}Filter Execution Order
Filters execute in registration order (first registered runs first on request, last on response):
app.MapPost("/products", Create)
.AddEndpointFilter<LoggingFilter>() // 1st on request, 3rd on response
.AddEndpointFilter<AuthorizationFilter>() // 2nd on request, 2nd on response
.AddEndpointFilter<ValidationFilter>(); // 3rd on request, 1st on responseTypedResults Patterns
Union Return Types
Use Results<T1, T2, ...> for compile-time checked multiple response types:
app.MapGet("/products/{id}", async Task<Results<Ok<ProductDto>, NotFound, BadRequest<ProblemDetails>>>
(int id, IProductService service) =>
{
if (id <= 0)
return TypedResults.BadRequest(new ProblemDetails
{
Title = "Invalid ID",
Detail = "Product ID must be positive"
});
var product = await service.GetByIdAsync(id);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
});Complex Result Patterns
// Paginated results
app.MapGet("/products", async Task<Ok<PagedResult<ProductDto>>>
([AsParameters] PaginationQuery query, IProductService service) =>
{
var result = await service.GetPagedAsync(query.Page, query.PageSize);
return TypedResults.Ok(result);
});
public record PaginationQuery(int Page = 1, int PageSize = 20);
public record PagedResult<T>(
IReadOnlyList<T> Items,
int TotalCount,
int Page,
int PageSize)
{
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public bool HasNextPage => Page < TotalPages;
public bool HasPreviousPage => Page > 1;
}File Results
app.MapGet("/products/{id}/export", async Task<Results<FileStreamHttpResult, NotFound>>
(int id, IProductService service) =>
{
var product = await service.GetByIdAsync(id);
if (product is null)
return TypedResults.NotFound();
var stream = await service.ExportToCsvAsync(product);
return TypedResults.File(stream, "text/csv", $"product-{id}.csv");
});Accepted with Location
app.MapPost("/products/import", async Task<Accepted<ImportJobResponse>>
(ImportRequest request, IImportService service) =>
{
var jobId = await service.StartImportAsync(request);
return TypedResults.Accepted(
$"/jobs/{jobId}",
new ImportJobResponse(jobId, "Processing"));
});Parameter Binding
[AsParameters] for Complex Queries
public record ProductSearchQuery(
string? Name,
decimal? MinPrice,
decimal? MaxPrice,
string? Category,
int Page = 1,
int PageSize = 20,
string SortBy = "name",
bool Descending = false);
app.MapGet("/products/search", async Task<Ok<PagedResult<ProductDto>>>
([AsParameters] ProductSearchQuery query, IProductService service) =>
{
var result = await service.SearchAsync(query);
return TypedResults.Ok(result);
});Header and Query Binding
app.MapGet("/products", async Task<Ok<List<ProductDto>>>
([FromHeader(Name = "X-Tenant-Id")] string tenantId,
[FromQuery] string? category,
IProductService service) =>
{
var products = await service.GetByTenantAsync(tenantId, category);
return TypedResults.Ok(products);
});Custom Model Binding
public record DateRange(DateOnly Start, DateOnly End) : IParsable<DateRange>
{
public static DateRange Parse(string s, IFormatProvider? provider)
{
var parts = s.Split("..");
return new DateRange(DateOnly.Parse(parts[0]), DateOnly.Parse(parts[1]));
}
public static bool TryParse(string? s, IFormatProvider? provider, out DateRange result)
{
result = default!;
if (string.IsNullOrEmpty(s)) return false;
var parts = s.Split("..");
if (parts.Length != 2) return false;
if (!DateOnly.TryParse(parts[0], out var start)) return false;
if (!DateOnly.TryParse(parts[1], out var end)) return false;
result = new DateRange(start, end);
return true;
}
}
// Usage: /orders?dateRange=2024-01-01..2024-12-31
app.MapGet("/orders", (DateRange dateRange) => ...);Error Handling
Global Exception Handler
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.ContentType = "application/problem+json";
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var problem = exception switch
{
ValidationException vex => new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation Error",
Detail = string.Join("; ", vex.Errors.Select(e => e.ErrorMessage))
},
NotFoundException => new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Not Found"
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred"
}
};
context.Response.StatusCode = problem.Status ?? 500;
await context.Response.WriteAsJsonAsync(problem);
});
});Result Pattern Integration
public static class ResultExtensions
{
public static IResult ToHttpResult<T>(this Result<T> result) =>
result.IsSuccess
? TypedResults.Ok(result.Value)
: result.Error switch
{
NotFoundError => TypedResults.NotFound(),
ValidationError ve => TypedResults.ValidationProblem(ve.Errors),
ConflictError ce => TypedResults.Conflict(ce.Message),
_ => TypedResults.Problem(result.Error.Message)
};
}
app.MapGet("/products/{id}", async (int id, IProductService service) =>
{
var result = await service.GetByIdAsync(id);
return result.ToHttpResult();
});OpenAPI Enhancements
Rich Metadata
app.MapGet("/products/{id}", GetProductById)
.WithName("GetProductById")
.WithSummary("Get a product by ID")
.WithDescription("Returns detailed information about a specific product including pricing and availability")
.Produces<ProductDto>(StatusCodes.Status200OK, "application/json")
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesValidationProblem()
.WithOpenApi(operation =>
{
operation.Parameters[0].Description = "The unique product identifier";
operation.Parameters[0].Example = new OpenApiInteger(42);
return operation;
});Request/Response Examples
app.MapPost("/products", CreateProduct)
.WithOpenApi(operation =>
{
operation.RequestBody.Content["application/json"].Example = new OpenApiObject
{
["name"] = new OpenApiString("Widget Pro"),
["price"] = new OpenApiDouble(29.99),
["category"] = new OpenApiString("Electronics")
};
return operation;
});Testing Patterns
WebApplicationFactory Setup
public class MinimalApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public MinimalApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddScoped<IProductService, MockProductService>();
});
}).CreateClient();
}
[Fact]
public async Task GetProducts_ReturnsOk()
{
var response = await _client.GetAsync("/api/products");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
products.Should().NotBeEmpty();
}
}Testing Filters in Isolation
[Fact]
public async Task ValidationFilter_InvalidInput_ReturnsBadRequest()
{
var filter = new FluentValidationFilter<CreateProductRequest>();
var httpContext = new DefaultHttpContext();
httpContext.RequestServices = new ServiceCollection()
.AddScoped<IValidator<CreateProductRequest>, CreateProductValidator>()
.BuildServiceProvider();
var context = new EndpointFilterInvocationContext(
httpContext,
new object[] { new CreateProductRequest("", -1) });
var result = await filter.InvokeAsync(context, _ =>
ValueTask.FromResult<object?>(TypedResults.Ok()));
result.Should().BeOfType<ValidationProblem>();
}