Minimal APIs were introduced in .NET 6 as a streamlined way to build HTTP APIs with minimal ceremony. Instead of controllers and action methods, you define endpoints directly in your Program.cs file using lambda expressions. This approach reduces boilerplate and makes it easier to create small, focused APIs.
This guide covers everything you need to know about building production-ready Minimal APIs.
Your First Minimal API
Here is the simplest possible Minimal API:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello, World!");
app.Run();
This creates a web server that responds with "Hello, World!" when you visit the root URL. Let us expand this to a more realistic example:
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// Configure middleware
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// Define endpoints
app.MapGet("/api/products", () =>
{
return new[]
{
new { Id = 1, Name = "Laptop", Price = 999.99m },
new { Id = 2, Name = "Mouse", Price = 29.99m },
new { Id = 3, Name = "Keyboard", Price = 79.99m }
};
});
app.Run();
Route Parameters
Minimal APIs support various ways to bind parameters from the request:
// Route parameter
app.MapGet("/api/products/{id}", (int id) =>
{
return Results.Ok(new { Id = id, Name = $"Product {id}" });
});
// Multiple route parameters
app.MapGet("/api/categories/{categoryId}/products/{productId}",
(int categoryId, int productId) =>
{
return Results.Ok(new { CategoryId = categoryId, ProductId = productId });
});
// Query string parameters
app.MapGet("/api/search", (string? q, int page = 1, int pageSize = 10) =>
{
return Results.Ok(new
{
Query = q,
Page = page,
PageSize = pageSize
});
});
// Header parameter
app.MapGet("/api/check-header", ([FromHeader(Name = "X-Custom-Header")] string? customHeader) =>
{
return Results.Ok(new { HeaderValue = customHeader });
});
Request Body Binding
For POST and PUT requests, you can bind the request body to a parameter:
public record CreateProductRequest(string Name, decimal Price, string? Description);
public record UpdateProductRequest(string? Name, decimal? Price, string? Description);
public record Product(int Id, string Name, decimal Price, string? Description);
app.MapPost("/api/products", (CreateProductRequest request) =>
{
var product = new Product(
Id: Random.Shared.Next(1000),
Name: request.Name,
Price: request.Price,
Description: request.Description
);
return Results.Created($"/api/products/{product.Id}", product);
});
app.MapPut("/api/products/{id}", (int id, UpdateProductRequest request) =>
{
// Update logic here
return Results.NoContent();
});
app.MapDelete("/api/products/{id}", (int id) =>
{
// Delete logic here
return Results.NoContent();
});
Dependency Injection
Minimal APIs fully support dependency injection. Services are resolved from parameters:
var builder = WebApplication.CreateBuilder(args);
// Register services
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();
var app = builder.Build();
// Inject services into handlers
app.MapGet("/api/products", async (IProductService productService) =>
{
var products = await productService.GetAllAsync();
return Results.Ok(products);
});
app.MapGet("/api/products/{id}", async (int id, IProductService productService) =>
{
var product = await productService.GetByIdAsync(id);
return product is not null
? Results.Ok(product)
: Results.NotFound();
});
app.MapPost("/api/products", async (
CreateProductRequest request,
IProductService productService,
ILogger<Program> logger) =>
{
logger.LogInformation("Creating product: {Name}", request.Name);
var product = await productService.CreateAsync(request);
return Results.Created($"/api/products/{product.Id}", product);
});
Validation
You can add validation using the FluentValidation library or built-in data annotations:
using FluentValidation;
public record CreateProductRequest(string Name, decimal Price, string? Description);
public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
public CreateProductRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
.MaximumLength(100).WithMessage("Name must be 100 characters or less");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be greater than zero");
RuleFor(x => x.Description)
.MaximumLength(500).WithMessage("Description must be 500 characters or less")
.When(x => x.Description is not null);
}
}
// Register validator
builder.Services.AddScoped<IValidator<CreateProductRequest>, CreateProductRequestValidator>();
// Use validation in handler
app.MapPost("/api/products", async (
CreateProductRequest request,
IValidator<CreateProductRequest> validator,
IProductService productService) =>
{
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
var product = await productService.CreateAsync(request);
return Results.Created($"/api/products/{product.Id}", product);
});
You can create an endpoint filter for automatic validation:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (argument is not null)
{
var validationResult = await _validator.ValidateAsync(argument);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
}
return await next(context);
}
}
// Usage
app.MapPost("/api/products", async (
CreateProductRequest request,
IProductService productService) =>
{
var product = await productService.CreateAsync(request);
return Results.Created($"/api/products/{product.Id}", product);
})
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
Authentication and Authorization
Minimal APIs support the same authentication and authorization as controller-based APIs:
var builder = WebApplication.CreateBuilder(args);
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(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"));
options.AddPolicy("PremiumUser", policy =>
policy.RequireClaim("subscription", "premium"));
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Public endpoint
app.MapGet("/api/public", () => "This is public");
// Authenticated endpoint
app.MapGet("/api/protected", (ClaimsPrincipal user) =>
{
return $"Hello, {user.Identity?.Name}";
})
.RequireAuthorization();
// Role-based authorization
app.MapGet("/api/admin", () => "Admin only content")
.RequireAuthorization("AdminOnly");
// Policy-based authorization
app.MapGet("/api/premium", () => "Premium content")
.RequireAuthorization("PremiumUser");
// Access user claims in handler
app.MapGet("/api/profile", (ClaimsPrincipal user) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var email = user.FindFirstValue(ClaimTypes.Email);
var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value);
return Results.Ok(new { UserId = userId, Email = email, Roles = roles });
})
.RequireAuthorization();
Route Groups
Organize related endpoints using route groups:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Create a route group for products
var products = app.MapGroup("/api/products")
.WithTags("Products");
products.MapGet("/", async (IProductService service) =>
await service.GetAllAsync());
products.MapGet("/{id}", async (int id, IProductService service) =>
await service.GetByIdAsync(id) is Product product
? Results.Ok(product)
: Results.NotFound());
products.MapPost("/", async (CreateProductRequest request, IProductService service) =>
{
var product = await service.CreateAsync(request);
return Results.Created($"/api/products/{product.Id}", product);
});
products.MapPut("/{id}", async (int id, UpdateProductRequest request, IProductService service) =>
{
await service.UpdateAsync(id, request);
return Results.NoContent();
});
products.MapDelete("/{id}", async (int id, IProductService service) =>
{
await service.DeleteAsync(id);
return Results.NoContent();
});
// Create an admin group with authorization
var admin = app.MapGroup("/api/admin")
.WithTags("Admin")
.RequireAuthorization("AdminOnly");
admin.MapGet("/users", async (IUserService service) =>
await service.GetAllUsersAsync());
admin.MapDelete("/users/{id}", async (int id, IUserService service) =>
{
await service.DeleteUserAsync(id);
return Results.NoContent();
});
app.Run();
Endpoint Filters
Endpoint filters are similar to action filters in MVC. They let you run code before and after handlers:
public class LoggingFilter : IEndpointFilter
{
private readonly ILogger<LoggingFilter> _logger;
public LoggingFilter(ILogger<LoggingFilter> logger)
{
_logger = logger;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var path = context.HttpContext.Request.Path;
var method = context.HttpContext.Request.Method;
_logger.LogInformation("Handling {Method} {Path}", method, path);
var stopwatch = Stopwatch.StartNew();
var result = await next(context);
stopwatch.Stop();
_logger.LogInformation(
"Completed {Method} {Path} in {ElapsedMs}ms",
method, path, stopwatch.ElapsedMilliseconds);
return result;
}
}
// Apply to individual endpoint
app.MapGet("/api/products", async (IProductService service) =>
await service.GetAllAsync())
.AddEndpointFilter<LoggingFilter>();
// Apply to group
var products = app.MapGroup("/api/products")
.AddEndpointFilter<LoggingFilter>();
Organizing Code with Extension Methods
As your API grows, move endpoint definitions to separate files:
// ProductEndpoints.cs
public static class ProductEndpoints
{
public static IEndpointRouteBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/products")
.WithTags("Products");
group.MapGet("/", GetAll);
group.MapGet("/{id}", GetById);
group.MapPost("/", Create);
group.MapPut("/{id}", Update);
group.MapDelete("/{id}", Delete);
return app;
}
private static async Task<IResult> GetAll(IProductService service)
{
var products = await service.GetAllAsync();
return Results.Ok(products);
}
private static async Task<IResult> GetById(int id, IProductService service)
{
var product = await service.GetByIdAsync(id);
return product is not null
? Results.Ok(product)
: Results.NotFound();
}
private static async Task<IResult> Create(
CreateProductRequest request,
IProductService service)
{
var product = await service.CreateAsync(request);
return Results.Created($"/api/products/{product.Id}", product);
}
private static async Task<IResult> Update(
int id,
UpdateProductRequest request,
IProductService service)
{
var exists = await service.ExistsAsync(id);
if (!exists)
{
return Results.NotFound();
}
await service.UpdateAsync(id, request);
return Results.NoContent();
}
private static async Task<IResult> Delete(int id, IProductService service)
{
var exists = await service.ExistsAsync(id);
if (!exists)
{
return Results.NotFound();
}
await service.DeleteAsync(id);
return Results.NoContent();
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapProductEndpoints();
app.MapOrderEndpoints();
app.MapUserEndpoints();
app.Run();
OpenAPI and Swagger
Minimal APIs integrate with OpenAPI for documentation:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "My API",
Version = "v1",
Description = "A sample API built with Minimal APIs"
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT"
});
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Add metadata to endpoints
app.MapGet("/api/products", async (IProductService service) =>
await service.GetAllAsync())
.WithName("GetProducts")
.WithSummary("Get all products")
.WithDescription("Returns a list of all products in the catalog")
.Produces<IEnumerable<Product>>(StatusCodes.Status200OK);
app.MapGet("/api/products/{id}", async (int id, IProductService service) =>
await service.GetByIdAsync(id) is Product product
? Results.Ok(product)
: Results.NotFound())
.WithName("GetProductById")
.WithSummary("Get product by ID")
.Produces<Product>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
app.MapPost("/api/products", async (
CreateProductRequest request,
IProductService service) =>
{
var product = await service.CreateAsync(request);
return Results.Created($"/api/products/{product.Id}", product);
})
.WithName("CreateProduct")
.WithSummary("Create a new product")
.Accepts<CreateProductRequest>("application/json")
.Produces<Product>(StatusCodes.Status201Created)
.Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest);
Error Handling
Implement global error handling:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(exceptionApp =>
{
exceptionApp.Run(async context =>
{
var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionFeature?.Error;
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An error occurred",
Detail = app.Environment.IsDevelopment() ? exception?.Message : null
};
context.Response.StatusCode = problemDetails.Status.Value;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problemDetails);
});
});
// Custom exception for not found
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}
// Handler that throws
app.MapGet("/api/products/{id}", async (int id, IProductService service) =>
{
var product = await service.GetByIdAsync(id);
if (product is null)
{
throw new NotFoundException($"Product with ID {id} not found");
}
return Results.Ok(product);
});
Conclusion
Minimal APIs provide a clean, concise way to build HTTP APIs in ASP.NET Core. They are well suited for microservices, small APIs, and scenarios where you want to minimize boilerplate.
Key takeaways:
- Use lambda expressions or method groups for handlers
- Leverage route groups to organize related endpoints
- Use endpoint filters for cross-cutting concerns
- Move endpoints to separate files as your API grows
- Add OpenAPI metadata for documentation
- Implement proper validation and error handling
Minimal APIs are not just for small projects. With proper organization using extension methods and route groups, they scale well to larger applications while maintaining their simplicity.