Minimal APIs in .NET: Best Practices and Patterns
Minimal APIs, introduced in .NET 6 and enhanced in subsequent versions, offer a lightweight approach to building HTTP APIs. While they start simple, production applications need structure. This guide covers patterns and practices for building maintainable Minimal APIs.
Why Minimal APIs?
Minimal APIs provide:
- Less ceremony than MVC controllers
- Faster startup time
- Native AOT support
- Great for microservices and simple APIs
Basic Structure
A simple Minimal API looks like this:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();But real applications need more structure.
Organizing Endpoints
The Problem with a Giant Program.cs
As your API grows, keeping everything in Program.cs becomes unmanageable. Here are patterns to organize your code.
Pattern 1: Extension Methods
Create extension methods for endpoint groups:
// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
public static IEndpointRouteBuilder MapProductEndpoints(
this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/products")
.WithTags("Products");
group.MapGet("/", GetAllProducts);
group.MapGet("/{id:int}", GetProductById);
group.MapPost("/", CreateProduct);
group.MapPut("/{id:int}", UpdateProduct);
group.MapDelete("/{id:int}", DeleteProduct);
return app;
}
private static async Task<IResult> GetAllProducts(
IProductRepository repository,
CancellationToken ct)
{
var products = await repository.GetAllAsync(ct);
return Results.Ok(products);
}
private static async Task<IResult> GetProductById(
int id,
IProductRepository repository,
CancellationToken ct)
{
var product = await repository.GetByIdAsync(id, ct);
return product is null
? Results.NotFound()
: Results.Ok(product);
}
// ... other handlers
}
// Program.cs
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapCustomerEndpoints();Pattern 2: Carter-Style Modules
Create endpoint modules using an interface:
public interface IEndpointModule
{
void MapEndpoints(IEndpointRouteBuilder app);
}
public class ProductModule : IEndpointModule
{
public void MapEndpoints(IEndpointRouteBuilder app)
{
app.MapGet("/api/products", GetProducts);
app.MapPost("/api/products", CreateProduct);
}
private static async Task<IResult> GetProducts(
IProductService service) => Results.Ok(await service.GetAllAsync());
private static async Task<IResult> CreateProduct(
CreateProductRequest request,
IProductService service)
{
var product = await service.CreateAsync(request);
return Results.Created($"/api/products/{product.Id}", product);
}
}
// Auto-register all modules
public static class EndpointExtensions
{
public static IEndpointRouteBuilder MapEndpointModules(
this IEndpointRouteBuilder app)
{
var moduleType = typeof(IEndpointModule);
var modules = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.Where(t => moduleType.IsAssignableFrom(t) && !t.IsInterface);
foreach (var module in modules)
{
var instance = Activator.CreateInstance(module) as IEndpointModule;
instance?.MapEndpoints(app);
}
return app;
}
}Validation with FluentValidation
Add robust validation using FluentValidation:
// Install FluentValidation.DependencyInjectionExtensions
public class CreateProductRequest
{
public string Name { get; init; } = string.Empty;
public decimal Price { get; init; }
public string Category { get; init; } = string.Empty;
}
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.Price)
.GreaterThan(0)
.LessThan(1_000_000);
RuleFor(x => x.Category)
.NotEmpty()
.MaximumLength(50);
}
}
// Validation filter
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var validator = context.HttpContext
.RequestServices.GetService<IValidator<T>>();
if (validator is null)
return await next(context);
var entity = context.Arguments.OfType<T>().FirstOrDefault();
if (entity is null)
return Results.BadRequest("Invalid request body");
var validation = await validator.ValidateAsync(entity);
if (!validation.IsValid)
{
return Results.ValidationProblem(
validation.ToDictionary());
}
return await next(context);
}
}
// Usage
app.MapPost("/api/products", CreateProduct)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();Error Handling with Problem Details
Implement consistent error responses:
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext context,
Exception exception,
CancellationToken ct)
{
_logger.LogError(exception, "Exception occurred: {Message}",
exception.Message);
var problemDetails = exception switch
{
ValidationException validationEx => new ProblemDetails
{
Status = StatusCodes.Status400BadRequest,
Title = "Validation Error",
Detail = validationEx.Message,
Extensions = { ["errors"] = validationEx.Errors }
},
NotFoundException notFoundEx => new ProblemDetails
{
Status = StatusCodes.Status404NotFound,
Title = "Not Found",
Detail = notFoundEx.Message
},
_ => new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Server Error",
Detail = "An unexpected error occurred"
}
};
context.Response.StatusCode = problemDetails.Status!.Value;
await context.Response.WriteAsJsonAsync(problemDetails, ct);
return true;
}
}
// Register in Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();Authentication and Authorization
Secure your endpoints:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"));
});
// Apply to endpoints
app.MapGet("/api/admin/users", GetUsers)
.RequireAuthorization("AdminOnly");
app.MapGet("/api/products", GetProducts)
.AllowAnonymous();
app.MapPost("/api/products", CreateProduct)
.RequireAuthorization();OpenAPI/Swagger Documentation
Document your API properly:
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Products API",
Version = "v1",
Description = "API for managing products"
});
});
// Enhanced endpoint documentation
app.MapGet("/api/products/{id}", GetProductById)
.WithName("GetProduct")
.WithSummary("Gets a product by ID")
.WithDescription("Returns a single product based on the provided ID")
.Produces<ProductResponse>(200)
.Produces(404)
.WithOpenApi();Typed Results in .NET 7+
Use TypedResults for better type safety:
app.MapGet("/api/products/{id}", async Task<Results<Ok<Product>, NotFound>> (
int id,
IProductRepository repository,
CancellationToken ct) =>
{
var product = await repository.GetByIdAsync(id, ct);
return product is null
? TypedResults.NotFound()
: TypedResults.Ok(product);
});Rate Limiting
Protect your API from abuse:
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
context =>
{
return RateLimitPartition.GetFixedWindowLimiter(
context.User.Identity?.Name ?? context.Request.Headers.Host.ToString(),
partition => new FixedWindowRateLimiterOptions
{
AutoReplenishment = true,
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
});
});
options.AddPolicy("api", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromSeconds(10)
}));
});
app.UseRateLimiter();
app.MapGet("/api/products", GetProducts)
.RequireRateLimiting("api");Best Practices Summary
- Organize endpoints into logical modules or extension methods
- Validate input using FluentValidation or similar libraries
- Use TypedResults for compile-time result type checking
- Implement proper error handling with ProblemDetails
- Document with OpenAPI for better developer experience
- Apply rate limiting to protect against abuse
- Use endpoint filters for cross-cutting concerns
- Leverage endpoint groups for shared configuration
Conclusion
Minimal APIs are production-ready when properly structured. By applying these patterns, you can build maintainable, well-documented, and secure APIs. Start simple, and add structure as your application grows.
The key is finding the right balance between the simplicity that makes Minimal APIs attractive and the organization that production code requires.
