Why Minimal APIs Win Over Controllers for New .NET 8 Microservices
When launching a new .NET 8 microservice, minimal APIs offer lower allocation, simpler DI, and built‑in OpenAPI support. This post compares them to controllers, walks through a CRUD example, and lists trade‑offs to help you decide.
27 Sept 2026, 15:21 UTC

Concrete Problem: Picking the Right API Style for a Fresh Microservice
When starting a new microservice in .NET 8, teams often debate whether to use the classic MVC Controller pattern or the newer Minimal API approach. Both compile to the same underlying IEndpointRouteBuilder infrastructure, but the surface API, performance characteristics, and tooling support differ enough to influence the long‑term health of a service.
Below is a focused comparison that covers four key areas:
- Runtime performance and allocation
- Dependency‑injection patterns and lifetime safety
- Testing and integration‑test friendliness
- OpenAPI generation and schema customization
We’ll finish with a worked CRUD example, a quick look at the trade‑offs, and a practical checklist for deciding.
1. Performance: RequestDelegateFactory vs. Controller Actions
In .NET 8, minimal APIs use RequestDelegateFactory to compile each route handler into a strongly‑typed delegate. BenchmarkDotNet studies on a typical CRUD workload show roughly 40 % fewer per‑request allocations compared to controller actions. The difference stems from:
- Eliminating the model‑binding pipeline that controllers rely on.
- Inlining parameter resolution directly into the delegate.
- Reducing reflection overhead for attribute discovery.
Typical latency improvements are 1–3 ms on a 2‑core Linux VM, which scales linearly as you add more concurrent requests.
2. DI Patterns and Lifetime Safety
Both styles resolve services from the same IServiceProvider, but the way you capture those services matters. In a minimal API you can:
- Constructor‑like injection: Pass services as method parameters annotated with
[FromServices].app.MapPost("/items", ([FromServices] IItemRepository repo, CreateItemDto dto) => { var id = repo.Add(dto); return Results.Created($"/items/{id}", id); }); - Lambda capture: Capture services from the surrounding scope.
var repo = app.Services.GetRequiredService(); app.MapPost("/items", (CreateItemDto dto) => { var id = repo.Add(dto); // Potential lifetime bug! return Results.Created($"/items/{id}", id); });
The second pattern is dangerous if repo is a scoped service (e.g., DbContext). Because the lambda is registered as a singleton delegate, the captured instance will live for the application's lifetime, breaking per‑request scoping. The safest practice is to use [FromServices] or IServiceScopeFactory to obtain scoped services inside the handler body.
3. Testing: Seamless Integration with WebApplicationFactory
Minimal APIs expose their route definitions directly on the WebApplication object. This makes integration tests straightforward with WebApplicationFactory<Program>:
public class ItemsApiTests : IClassFixture<WebApplicationFactory<Program>> {
private readonly HttpClient _client;
public ItemsApiTests(WebApplicationFactory<Program> factory) {
_client = factory.CreateClient();
}
[Fact]
public async Task CreateItem_Returns201() {
var response = await _client.PostAsJsonAsync("/items", new { Name = "Foo" });
response.StatusCode.Should().Be(StatusCodes.Status201Created);
}
}
Unlike controller tests that require ControllerContext setup, minimal API tests hit the real routing and model binding pipeline, giving higher confidence.
4. OpenAPI Generation: Metadata and Customization
The Microsoft.AspNetCore.OpenApi package automatically turns route metadata into a Swagger/OpenAPI document. For minimal APIs you add .WithOpenApi() on each endpoint or use a DocumentTransformer for global tweaks:
app.MapGet("/items/{id}", (int id, [FromServices] IItemRepository repo) => {
var item = repo.Get(id);
return item is null ? Results.NotFound() : Results.Ok(item);
})
.WithName("GetItem")
.WithOpenApi();
Note that attribute‑based response annotations like [ProducesResponseType] are not recognised on lambdas. Use the fluent .Produces<T>(StatusCodes.Status200OK) syntax or a DocumentTransformer to enrich the spec.
Worked Example: CRUD Group with MapGroup and Endpoint Filters
Below is a minimal yet complete CRUD setup that demonstrates routing, DI, validation, and OpenAPI integration. Assume a simple IItemRepository that uses an in‑memory list.
public record CreateItemDto(string Name);
public record Item(int Id, string Name);
public interface IItemRepository {
int Add(CreateItemDto dto);
Item? Get(int id);
bool Update(int id, CreateItemDto dto);
bool Delete(int id);
}
public class InMemoryItemRepository : IItemRepository {
private readonly List<Item> _items = new();
private int _nextId = 1;
public int Add(CreateItemDto dto) {
var item = new Item(_nextId++, dto.Name);
_items.Add(item);
return item.Id;
}
public Item? Get(int id) => _items.FirstOrDefault(i => i.Id == id);
public bool Update(int id, CreateItemDto dto) {
var item = Get(id);
if (item is null) return false;
_items.Remove(item);
_items.Add(new Item(id, dto.Name));
return true;
}
public bool Delete(int id) => _items.RemoveAll(i => i.Id == id) > 0;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IItemRepository, InMemoryItemRepository>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
var items = app.MapGroup("/items");
items.WithOpenApi();
items.MapGet("", ([FromServices] IItemRepository repo) => Results.Ok(repo.GetAll()))
.Produces<IEnumerable<Item>>(StatusCodes.Status200OK)
.WithName("GetAllItems");
items.MapPost("", ([FromServices] IItemRepository repo, CreateItemDto dto) => {
var id = repo.Add(dto);
return Results.Created($"/items/{id}", id);
})
.Produces<int>(StatusCodes.Status201Created)
.WithName("CreateItem");
items.MapGet("/{id:int}", (int id, [FromServices] IItemRepository repo) => {
var item = repo.Get(id);
return item is null ? Results.NotFound() : Results.Ok(item);
})
.Produces<Item>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.WithName("GetItem");
items.MapPut("/{id:int}", (int id, [FromServices] IItemRepository repo, CreateItemDto dto) => {
var success = repo.Update(id, dto);
return success ? Results.NoContent() : Results.NotFound();
})
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.WithName("UpdateItem");
items.MapDelete("/{id:int}", (int id, [FromServices] IItemRepository repo) => {
var success = repo.Delete(id);
return success ? Results.NoContent() : Results.NotFound();
})
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.WithName("DeleteItem");
app.Run();
Key take‑aways from the example:
- All services are resolved via
[FromServices], keeping lifetimes safe. - Route grouping with
MapGroupkeeps the URL space tidy and allows bulk security policies with.RequireAuthorization(). - Fluent
.Producescalls feed the OpenAPI spec, so the generated Swagger UI shows accurate response types. - No controller classes mean less boilerplate and a flatter file structure.
Trade‑Offs and Limitations
- Model validation: Minimal APIs do not automatically enforce
[Required]or[Range]attributes. You must use FluentValidation, manual checks, or anIEndpointFilterto return a 400 on invalid input. - Attribute‑based routing: Complex attribute routing (e.g., multiple route templates per action) is less natural; use
MapGroupor explicit route strings instead. - Legacy tooling: Some older libraries expect controller conventions (e.g.,
ApiControllerattribute). Migration may require adapters. - AOT and trimming: Native AOT publishing works but requires trim‑safe libraries and source‑generated JSON contexts. Test AOT early if you plan to publish as a single‑file executable.
- File uploads: By default
IFormFileis buffered. For large uploads, useRequest.BodyReaderwithPipeReaderto stream data.
Actionable Checklist for the Decision
- Do you need the lowest possible per‑request allocation?
Minimal APIwins. - Will you use scoped services like
DbContext? Ensure you use[FromServices]or a scoped factory. - Do you rely heavily on automatic model validation? Consider a validation filter or stay with controllers.
- Is AOT publishing a requirement? Verify trim‑safe libraries and source generation early.
- Do you need complex attribute routing or legacy middleware integration? Controllers may be simpler.
For a brand‑new microservice that values performance, clean DI, and modern OpenAPI tooling, minimal APIs are the pragmatic choice. If your team already has a mature controller codebase, you can gradually migrate by adding MapGet/MapPost endpoints alongside MapControllers() and refactor incrementally.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.