Minimal APIs chegaram no .NET 6 e amadureceram no .NET 8 com recursos que eliminavam as principais críticas da abordagem: falta de organização para APIs grandes, validação integrada e suporte completo a filtros. Hoje são uma alternativa real aos Controllers MVC para microsserviços, BFFs e APIs de domínio focado.
Controllers MVC vs Minimal APIs: qual escolher?
A resposta depende do tamanho e do time. Controllers MVC têm estrutura forçada — útil para times grandes com convenções definidas. Minimal APIs dão mais flexibilidade e menos cerimônia — ótimas para microsserviços, APIs com poucos endpoints e squads experientes em .NET. Para uma API nova com menos de 30 endpoints, Minimal APIs são a escolha mais limpa no .NET 8.
Setup básico no .NET 8
// Program.cs mínimo — API de produtos:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IProdutoService, ProdutoService>();
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); // FluentValidation
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Mapeamento direto — sem Controller, sem atributos de rota em classe:
app.MapGet("/produtos", GetProdutos);
app.MapGet("/produtos/{id:guid}", GetProduto);
app.MapPost("/produtos", CriarProduto).RequireAuthorization("admin");
app.MapPut("/produtos/{id:guid}", AtualizarProduto).RequireAuthorization("admin");
app.MapDelete("/produtos/{id:guid}", DeletarProduto).RequireAuthorization("admin");
app.Run();
Organização com Endpoint Groups (a solução para APIs grandes)
A crítica mais comum às Minimal APIs era "vai virar um Program.cs de 2000 linhas". No .NET 7+ isso se resolve com MapGroup e extensões por feature:
// Cada feature tem seu próprio arquivo de extensão:// Features/Produtos/ProdutosEndpoints.cs public static class ProdutosEndpoints { public static RouteGroupBuilder MapProdutos(this RouteGroupBuilder group) { group.MapGet(”/”, GetProdutos); group.MapGet(”/{id:guid}”, GetProduto); group.MapPost(”/”, CriarProduto).RequireAuthorization(); group.MapPut(”/{id:guid}”, AtualizarProduto).RequireAuthorization(); group.MapDelete(”/{id:guid}”, DeletarProduto).RequireAuthorization(“admin”); return group; }
private static async Task<IResult> GetProdutos( IProdutoService svc, [AsParameters] ProdutosQuery query, // query string como record tipado CancellationToken ct) { var produtos = await svc.ListarAsync(query, ct); return TypedResults.Ok(produtos); } private static async Task<IResult> GetProduto( Guid id, IProdutoService svc, CancellationToken ct) { var produto = await svc.GetByIdAsync(id, ct); return produto is null ? TypedResults.NotFound() : TypedResults.Ok(produto); } private static async Task<IResult> CriarProduto( CriarProdutoRequest request, IValidator<CriarProdutoRequest> validator, IProdutoService svc, CancellationToken ct) { var validation = await validator.ValidateAsync(request, ct); if (!validation.IsValid) return TypedResults.ValidationProblem(validation.ToDictionary()); var produto = await svc.CriarAsync(request, ct); return TypedResults.Created($"/produtos/{produto.Id}", produto); }}
// Features/Pedidos/PedidosEndpoints.cs — mesmo padrão public static class PedidosEndpoints { public static RouteGroupBuilder MapPedidos(this RouteGroupBuilder group) { group.MapGet(”/”, GetPedidos); group.MapPost(”/”, CriarPedido); return group; } // … }
// Program.cs limpo — apenas registros: var apiV1 = app.MapGroup(“/api/v1”);
apiV1.MapGroup(“/produtos”) .MapProdutos() .WithTags(“Produtos”) .WithOpenApi();
apiV1.MapGroup(“/pedidos”) .MapPedidos() .WithTags(“Pedidos”) .WithOpenApi();
apiV1.MapGroup(“/usuarios”) .MapUsuarios() .RequireAuthorization() // autorização para o grupo inteiro .WithTags(“Usuários”) .WithOpenApi();
Validação com FluentValidation e filtro global
// Validator para CriarProdutoRequest: public class CriarProdutoRequestValidator : AbstractValidator<CriarProdutoRequest> { public CriarProdutoRequestValidator() { RuleFor(x => x.Nome) .NotEmpty().WithMessage("Nome é obrigatório") .MaximumLength(200).WithMessage("Nome deve ter no máximo 200 caracteres");RuleFor(x => x.Preco) .GreaterThan(0).WithMessage("Preço deve ser positivo"); RuleFor(x => x.Categoria) .NotEmpty() .Must(c => new[] { "eletronicos", "vestuario", "alimentos" }.Contains(c)) .WithMessage("Categoria inválida"); RuleFor(x => x.Estoque) .GreaterThanOrEqualTo(0).WithMessage("Estoque não pode ser negativo"); }}
// Para evitar repetir a validação em cada endpoint, crie um filtro: public class ValidationFilter<T> : IEndpointFilter where T : class { public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var validator = context.HttpContext.RequestServices .GetRequiredService<IValidator<T>>();
var request = context.Arguments.OfType<T>().FirstOrDefault(); if (request == null) return await next(context); var result = await validator.ValidateAsync(request); if (!result.IsValid) return TypedResults.ValidationProblem(result.ToDictionary()); return await next(context); }}
// Aplicar no grupo — valida automaticamente todos os POST/PUT do grupo: apiV1.MapGroup(“/produtos”) .MapProdutos() .AddEndpointFilterFactory((context, next) => { // Aplica ValidationFilter apenas em endpoints que recebem CriarProdutoRequest var paramType = context.MethodInfo.GetParameters() .FirstOrDefault(p => p.ParameterType == typeof(CriarProdutoRequest));
if (paramType != null) return invContext => new ValidationFilter<CriarProdutoRequest>() .InvokeAsync(invContext, next); return next; });
TypedResults: respostas fortemente tipadas
TypedResults (plural) em vez de Results (singular) melhora a documentação OpenAPI automática — o Swagger consegue inferir os tipos de retorno sem precisar de atributos [ProducesResponseType]:
// Com TypedResults, o OpenAPI infere os tipos automaticamente: private static async Task<Results<Ok<ProdutoDto>, NotFound>> GetProduto( Guid id, IProdutoService svc, CancellationToken ct) { var produto = await svc.GetByIdAsync(id, ct); return produto is null ? TypedResults.NotFound() : TypedResults.Ok(produto); // O compilador verifica que só Ok<ProdutoDto> e NotFound são retornos válidos }private static async Task<Results<Created<ProdutoDto>, ValidationProblem>> CriarProduto( CriarProdutoRequest request, IValidator<CriarProdutoRequest> validator, IProdutoService svc, CancellationToken ct) { var validation = await validator.ValidateAsync(request, ct); if (!validation.IsValid) return TypedResults.ValidationProblem(validation.ToDictionary());
var produto = await svc.CriarAsync(request, ct); return TypedResults.Created($"/produtos/{produto.Id}", produto);
}
Query string como record tipado com [AsParameters]
// Sem [AsParameters]: parâmetros individuais na assinatura do método // app.MapGet("/produtos", (string? nome, decimal? precoMin, int page = 1) => ...)// Com [AsParameters]: agrupa em record — mais limpo e reutilizável public record ProdutosQuery( [FromQuery] string? Nome, [FromQuery] decimal? PrecoMin, [FromQuery] decimal? PrecoMax, [FromQuery] string? Categoria, [FromQuery] int Page = 1, [FromQuery] int PageSize = 20 );
private static async Task<Ok<PagedResult<ProdutoDto>>> GetProdutos( [AsParameters] ProdutosQuery query, // toda a query string mapeada em 1 parâmetro IProdutoService svc, CancellationToken ct) { var result = await svc.ListarAsync(query, ct); return TypedResults.Ok(result); }
Testando Minimal APIs com WebApplicationFactory
// Testes de integração idênticos aos de Controllers: public class ProdutosIntegrationTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client;public ProdutosIntegrationTests(WebApplicationFactory<Program> factory) { _client = factory .WithWebHostBuilder(builder => builder.ConfigureServices(services => { // Substitui banco real por SQLite em memória services.RemoveAll<AppDbContext>(); services.AddDbContext<AppDbContext>(opts => opts.UseSqlite("Data Source=:memory:")); })) .CreateClient(); } [Fact] public async Task GetProduto_QuandoExiste_RetornaOk() { var response = await _client.GetAsync("/api/v1/produtos/guid-valido"); response.EnsureSuccessStatusCode(); var produto = await response.Content.ReadFromJsonAsync<ProdutoDto>(); Assert.NotNull(produto); } [Fact] public async Task CriarProduto_SemNome_RetornaValidationProblem() { var response = await _client.PostAsJsonAsync("/api/v1/produtos", new { preco = 100.0, categoria = "eletronicos", estoque = 10 }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); }
}
Quando NÃO usar Minimal APIs
Se o projeto tem controllers MVC já consolidados, migrar não traz benefício prático. Se o time tem menos experiência com .NET, a estrutura forçada dos Controllers MVC reduz erros. Para APIs com muitos filtros de ação reutilizáveis e model binding complexo (formulários multipart, custom model binders), Controllers ainda são mais convenientes.
Conclusão
Minimal APIs no .NET 8 com MapGroup, TypedResults e [AsParameters] são produtivas e bem organizadas para microsserviços e APIs de domínio. O padrão de extensão por feature resolve o problema de organização, e a validação com FluentValidation + filtro global elimina a repetição. Para novos projetos .NET com time experiente, é a abordagem mais limpa disponível hoje.
Se você está iniciando um novo microsserviço .NET ou revisando a arquitetura da sua API, a Neryx pode ajudar a definir os padrões certos desde o início. Consultoria inicial gratuita.
Leitura complementar: