Controllers aren’t going away, but Minimal APIs have matured into a first-class option for building REST APIs in ASP.NET Core. As of .NET 8, they support everything controllers do — validation, filters, versioning, OpenAPI — with less ceremony and faster startup.
This guide builds a real API step by step. Not a hello-world — a structured, tested, deployable API with auth, validation, and documentation.
Table of Contents
- Minimal APIs vs. Controllers: When to Choose What
- Project Setup and Structure
- Routing and Route Handlers
- Dependency Injection and Services
- Request Validation
- JWT Authentication
- OpenAPI and Swagger
- API Versioning
- FAQ
Minimal APIs vs. Controllers: When to Choose What
The honest comparison:
- Minimal APIs: Less boilerplate, faster startup, better for microservices, function-per-file organization is natural, excellent for CQRS-style handlers.
- Controllers: Familiar to most .NET devs, attribute-based routing is mature, better for teams coming from MVC, action filters are powerful.
In 2025, both are full-featured. The choice is mostly about team preference and project scale. This guide uses Minimal APIs throughout.
Project Setup and Structure
dotnet new webapi -n ProductsApi --use-minimal-apis
cd ProductsApi
Recommended folder structure for a non-trivial Minimal API project:
ProductsApi/
├── Program.cs # App bootstrap and route registration
├── Endpoints/
│ ├── ProductEndpoints.cs # Route registration extension methods
│ └── OrderEndpoints.cs
├── Models/
│ ├── Product.cs
│ └── CreateProductRequest.cs
├── Services/
│ └── ProductService.cs
└── Data/
└── AppDbContext.cs
Keeping routes out of Program.cs scales to real projects. Each endpoint group gets its own file as an extension method:
// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
public static void MapProductEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/products")
.WithTags("Products")
.RequireAuthorization();
group.MapGet("/", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create);
group.MapPut("/{id:int}", Update);
group.MapDelete("/{id:int}", Delete);
}
// handlers defined as static methods below...
}
// Program.cs — clean and minimal
var builder = WebApplication.CreateBuilder(args);
// ... service registration ...
var app = builder.Build();
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.Run();
Routing and Route Handlers
Basic CRUD Handlers
// GET /api/products
static async Task<Ok<List<ProductDto>>> GetAll(
IProductService productService,
CancellationToken ct)
{
var products = await productService.GetAllAsync(ct);
return TypedResults.Ok(products);
}
// GET /api/products/42
static async Task<Results<Ok<ProductDto>, NotFound>> GetById(
int id,
IProductService productService,
CancellationToken ct)
{
var product = await productService.GetByIdAsync(id, ct);
return product is null
? TypedResults.NotFound()
: TypedResults.Ok(product);
}
// POST /api/products
static async Task<Results<Created<ProductDto>, ValidationProblem>> Create(
CreateProductRequest request,
IProductService productService,
CancellationToken ct)
{
var product = await productService.CreateAsync(request, ct);
return TypedResults.Created($"/api/products/{product.Id}", product);
}
TypedResults: Better Than IResult
Use TypedResults (plural) over Results for your return types. TypedResults preserves the concrete return type in OpenAPI schema generation, so Swagger sees the actual response models — not just IResult.
Route Constraints
app.MapGet("/api/products/{id:int:min(1)}", GetById); // int, minimum 1
app.MapGet("/api/users/{username:alpha}", GetUser); // letters only
app.MapGet("/api/files/{filename:regex(^[a-z]+\\.txt$)}", GetFile); // regex
Dependency Injection and Services
// Register services
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
Minimal API handlers automatically resolve parameters from DI — no [FromServices] attribute needed for registered services:
// Parameters are resolved from:
// - Route: int id (from /products/{id})
// - Body: CreateProductRequest (JSON body, auto-deserialized)
// - DI: IProductService (from service container)
// - Framework: CancellationToken (automatic)
static async Task<Results<Ok<ProductDto>, NotFound>> GetById(
int id, // route
IProductService productService, // DI
CancellationToken ct) // framework
Request Validation
ASP.NET Core doesn’t validate Data Annotations on Minimal API request bodies by default. You have two good options:
Option 1: FluentValidation with a Filter
dotnet add package FluentValidation.AspNetCore
// Validator
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Price).GreaterThan(0);
RuleFor(x => x.CategoryId).GreaterThan(0);
}
}
// Validation filter
public class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var validator = context.HttpContext.RequestServices
.GetRequiredService<IValidator<T>>();
var arg = context.Arguments.OfType<T>().First();
var result = await validator.ValidateAsync(arg);
if (!result.IsValid)
return TypedResults.ValidationProblem(result.ToDictionary());
return await next(context);
}
}
// Apply to route
group.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
Option 2: .NET 8 Built-in Validation (IEndpointFilter)
[FromBody]
public record CreateProductRequest(
[Required, MaxLength(100)] string Name,
[Range(0.01, double.MaxValue)] decimal Price
);
JWT Authentication
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
builder.Services.AddAuthorization();
// After Build():
app.UseAuthentication();
app.UseAuthorization();
// Protect specific route groups
var protectedGroup = app.MapGroup("/api/products")
.RequireAuthorization(); // requires authenticated user
var adminGroup = app.MapGroup("/api/admin")
.RequireAuthorization("AdminPolicy"); // requires specific policy
// Allow anonymous on specific endpoints within a protected group
protectedGroup.MapGet("/public", GetPublic).AllowAnonymous();
Token Generation Endpoint
app.MapPost("/auth/token", (LoginRequest request, IConfiguration config) =>
{
// In production: validate against real user store
if (request.Username != "admin" || request.Password != "password")
return Results.Unauthorized();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: config["Jwt:Issuer"],
audience: config["Jwt:Audience"],
claims: new[] { new Claim(ClaimTypes.Name, request.Username) },
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds);
return Results.Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token) });
}).AllowAnonymous();
OpenAPI and Swagger
.NET 9 ships a new built-in OpenAPI document generator — Microsoft.AspNetCore.OpenApi — that replaces Swashbuckle for many use cases:
dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCore // modern UI alternative to Swagger UI
// Program.cs
builder.Services.AddOpenApi();
// After Build():
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/v1.json
app.MapScalarApiReference(); // /scalar/v1 — modern, dark-mode UI
}
// Enrich individual endpoints
group.MapGet("/{id:int}", GetById)
.WithName("GetProductById")
.WithSummary("Get a product by ID")
.WithDescription("Returns a single product or 404 if not found")
.Produces<ProductDto>(200)
.Produces(404);
API Versioning
dotnet add package Asp.Versioning.Http
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(), // /api/v1/products
new HeaderApiVersionReader("X-Api-Version"), // header
new QueryStringApiVersionReader("api-version") // ?api-version=1.0
);
});
var v1 = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1))
.HasApiVersion(new ApiVersion(2))
.ReportApiVersions()
.Build();
app.MapGet("/api/v{version:apiVersion}/products", GetAll)
.WithApiVersionSet(v1)
.MapToApiVersion(1);
app.MapGet("/api/v{version:apiVersion}/products", GetAllV2) // V2 handler
.WithApiVersionSet(v1)
.MapToApiVersion(2);
[INTERNAL_LINK: ASP.NET Core middleware pipeline explained]
FAQ
Should I use Minimal APIs or Controllers for a new project in 2025?
Both are solid choices. For new greenfield APIs, Minimal APIs offer less ceremony and better performance. For teams already comfortable with controllers, or APIs with complex action filter requirements, controllers still work great. The performance difference is real but rarely the deciding factor.
Do Minimal APIs support model binding from form data and files?
Yes. Use IFormFile for file uploads and [FromForm] for form data. File uploads work the same as in controllers — access via IFormFile parameter in your handler.
How do I handle global error handling in Minimal APIs?
Use app.UseExceptionHandler() for unhandled exceptions, and return TypedResults.Problem() (RFC 7807 Problem Details) for handled error cases. In .NET 8+, app.UseExceptionHandler() automatically returns Problem Details format.
Can I use action filters from MVC with Minimal APIs?
Not directly — action filters are MVC-specific. Minimal APIs use IEndpointFilter instead. The concept is similar but the interface differs. Convert your action filters to endpoint filters when migrating.
How do I test Minimal API endpoints?
Use WebApplicationFactory<Program> for integration tests — exactly the same as controller-based APIs. The test setup is identical. For unit testing individual handlers, since they’re static methods, you can call them directly with mocked dependencies.
Minimal APIs in .NET 8/9 are production-ready with everything you need for real-world services. The initial simplicity doesn’t come at the cost of capability — you can add versioning, auth, validation, and documentation incrementally as your API grows.
[INTERNAL_LINK: Entity Framework Core with ASP.NET Core guide]

Leave a Reply