.NET Minimal APIs C# ASP.NET Core Backend

Minimal APIs no .NET 8: quando usar, organização e validação com FluentValidation

Guia prático de Minimal APIs no .NET 8: diferenças com Controllers MVC, organização por feature com endpoint groups, validação com FluentValidation.

N
Neryx Digital Architects
27 de dezembro de 2025
11 min de leitura
230 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

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&lt;IResult&gt; 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&lt;IResult&gt; 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&lt;IResult&gt; CriarProduto(
    CriarProdutoRequest request,
    IValidator&lt;CriarProdutoRequest&gt; 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&lt;T&gt;().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&lt;CriarProdutoRequest&gt;()
            .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&lt;Program&gt; factory)
{
    _client = factory
        .WithWebHostBuilder(builder =>
            builder.ConfigureServices(services =>
            {
                // Substitui banco real por SQLite em memória
                services.RemoveAll&lt;AppDbContext&gt;();
                services.AddDbContext&lt;AppDbContext&gt;(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&lt;ProdutoDto&gt;();
    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:

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.