With .NET 6, Microsoft introduced Minimal APIs — a lightweight alternative to the classic controller-based APIs in ASP.NET Core. Instead of classes decorated with [ApiController] attributes and Route annotations, endpoints are registered directly in Program.cs. The result is significantly less boilerplate, especially for simple microservices.
Classic Controller API vs. Minimal API
Controller-Based (Classic)
// Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Product>>> GetAll()
{
var products = await _productService.GetAllAsync();
return Ok(products);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<Product>> GetById(int id)
{
var product = await _productService.GetByIdAsync(id);
return product is null ? NotFound() : Ok(product);
}
[HttpPost]
public async Task<ActionResult<Product>> Create([FromBody] ProductDto dto)
{
var created = await _productService.CreateAsync(dto);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
}
Minimal API (from .NET 6)
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProductService, ProductService>();
var app = builder.Build();
// GET /api/products
app.MapGet("/api/products", async (IProductService service) =>
await service.GetAllAsync());
// GET /api/products/{id}
app.MapGet("/api/products/{id:int}", async (int id, IProductService service) =>
{
var product = await service.GetByIdAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
// POST /api/products
app.MapPost("/api/products", async (ProductDto dto, IProductService service) =>
{
var created = await service.CreateAsync(dto);
return Results.Created($"/api/products/{created.Id}", created);
});
// DELETE /api/products/{id}
app.MapDelete("/api/products/{id:int}", async (int id, IProductService service) =>
{
var deleted = await service.DeleteAsync(id);
return deleted ? Results.NoContent() : Results.NotFound();
});
app.Run();
Complete Setup
Project Structure
MyApi/
├── Program.cs # Entry point and routing
├── Models/
│ ├── Product.cs # Entity
│ └── ProductDto.cs # Data Transfer Object (record)
├── Services/
│ ├── IProductService.cs
│ └── ProductService.cs
└── Data/
└── AppDbContext.cs # Entity Framework Context
Models as Records
// Models/Product.cs
public record Product(int Id, string Name, decimal Price, string Category);
// Models/ProductDto.cs — for POST/PUT requests
public record ProductDto(
[Required] string Name,
[Range(0.01, double.MaxValue)] decimal Price,
[Required] string Category
);
Entity Framework with SQLite (for Development)
// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) {}
public DbSet<Product> Products => Set<Product>();
}
// Program.cs — register EF
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=products.db"));
Service Layer
// Services/IProductService.cs
public interface IProductService
{
Task<IEnumerable<Product>> GetAllAsync();
Task<Product?> GetByIdAsync(int id);
Task<Product> CreateAsync(ProductDto dto);
Task<bool> DeleteAsync(int id);
}
// Services/ProductService.cs
public class ProductService : IProductService
{
private readonly AppDbContext _context;
public ProductService(AppDbContext context)
{
_context = context;
}
public async Task<IEnumerable<Product>> GetAllAsync()
=> await _context.Products.ToListAsync();
public async Task<Product?> GetByIdAsync(int id)
=> await _context.Products.FindAsync(id);
public async Task<Product> CreateAsync(ProductDto dto)
{
var product = new Product(0, dto.Name, dto.Price, dto.Category);
_context.Products.Add(product);
await _context.SaveChangesAsync();
return product;
}
public async Task<bool> DeleteAsync(int id)
{
var product = await _context.Products.FindAsync(id);
if (product is null) return false;
_context.Products.Remove(product);
await _context.SaveChangesAsync();
return true;
}
}
Validation and Error Handling
Minimal APIs use IValidator from FluentValidation or the built-in DataAnnotations validation:
// Program.cs with validation
app.MapPost("/api/products", async (
[FromBody] ProductDto dto,
IProductService service,
HttpContext context) =>
{
// Manual validation with DataAnnotations
var validationResults = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(
dto,
new ValidationContext(dto),
validationResults,
validateAllProperties: true
);
if (!isValid)
{
return Results.ValidationProblem(validationResults
.GroupBy(v => v.MemberNames.FirstOrDefault() ?? "")
.ToDictionary(g => g.Key, g => g.Select(v => v.ErrorMessage!).ToArray()));
}
var created = await service.CreateAsync(dto);
return Results.Created($"/api/products/{created.Id}", created);
});
Simpler with the route group extension:
// Organize endpoints into groups (from .NET 7)
var productsGroup = app.MapGroup("/api/products")
.WithTags("Products")
.WithOpenApi();
productsGroup.MapGet("/", GetAll);
productsGroup.MapGet("/{id:int}", GetById);
productsGroup.MapPost("/", Create).AddEndpointFilter<ValidationFilter<ProductDto>>();
OpenAPI / Swagger Integration
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// ...
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// Document individual endpoints
app.MapGet("/api/products", async (IProductService service) =>
await service.GetAllAsync())
.WithName("GetAllProducts")
.WithSummary("Retrieve all products")
.Produces<IEnumerable<Product>>(200)
.WithOpenApi();
Comparison with PHP/Symfony
For PHP developers, Minimal APIs are comparable to a Symfony controller that only defines routes:
// Symfony: Route definitions in the controller
#[Route('/api/products')]
class ProductController extends AbstractController
{
#[Route('', methods: ['GET'])]
public function list(): JsonResponse { /* ... */ }
}
// C# Minimal API: Route definitions directly in Program.cs
app.MapGet("/api/products", async (IProductService service) => /* ... */);
The key difference: In Symfony, the controller class serves as the organizational framework, while in Minimal APIs you can either write everything in Program.cs or extract methods into separate files.
When to Use Minimal APIs vs. Controllers
| Criterion | Minimal APIs | Controllers |
|---|---|---|
| Boilerplate | Minimal | More |
| Microservices | Ideal | Acceptable |
| Large APIs | Can become unwieldy | Better structured |
| Filters/Policies | Manual | Attribute-based |
| Conventions | Few | Many |
| Testing | Easy | Easy |
Minimal APIs are well suited for: small microservices, Function-as-a-Service scenarios, prototypes, and APIs with few endpoints.
Controllers are well suited for: large APIs with many endpoints, and when conventions and attribute-based policies are important.
Conclusion
Minimal APIs are not a replacement for classic controllers but rather a complement. They significantly reduce boilerplate and make small .NET APIs more accessible — especially for developers coming from other languages. For PHP developers looking to get started with C#, Minimal APIs are an excellent entry point.
Comments
Comments are provided by Remark42. By loading comments, data is transmitted to our comment server.