.NET C# Minimal APIs Backend Arquitetura

Minimal APIs com grupos tipados no .NET 9: organização sem Controllers e sem Carter

Como organizar Minimal APIs em .NET 9 com RouteGroupBuilder, grupos tipados via interfaces, validação integrada e modularização por feature — sem depender.

N
Neryx Digital Architects
9 de março de 2026
14 min de leitura
270 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

Minimal APIs chegaram no .NET 6 com uma proposta clara: endpoints sem o overhead de controllers. O problema é que exemplos simples de tutorial não escalam — colocar tudo em Program.cs vira um caos rapidamente. O .NET 7 trouxe RouteGroupBuilder e o .NET 9 consolidou os padrões para organizar APIs complexas sem precisar de libraries externas como Carter ou FastEndpoints. Este artigo mostra como estruturar Minimal APIs de produção com grupos tipados.

O problema do Program.cs monolítico

O exemplo canônico de Minimal API não sobrevive a uma aplicação real:

// Isso funciona em tutorial. Em produção com 40 endpoints: um desastre
app.MapGet("/pedidos", GetPedidos);
app.MapGet("/pedidos/{id}", GetPedidoById);
app.MapPost("/pedidos", CriarPedido);
app.MapPut("/pedidos/{id}", AtualizarPedido);
app.MapDelete("/pedidos/{id}", DeletarPedido);
app.MapGet("/clientes", GetClientes);
app.MapGet("/clientes/{id}", GetClienteById);
// ... mais 35 endpoints ...

A solução não é voltar para controllers — é organizar os endpoints em módulos usando a interface IEndpointRouteBuilder e grupos.

O padrão: IEndpointRouteBuilder como interface de módulo

O ponto de partida é uma interface simples que todos os módulos implementam:

// Contrato comum para todos os módulos de endpoints
public interface IEndpointModule
{
    void MapEndpoints(IEndpointRouteBuilder app);
}

Cada feature fica em sua própria classe. A convenção é uma classe por feature, no mesmo diretório:

// Features/Pedidos/PedidosModule.cs
public class PedidosModule : IEndpointModule
{
    public void MapEndpoints(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/pedidos")
            .WithTags("Pedidos")
            .RequireAuthorization();

        group.MapGet("/", ListarPedidos)
            .WithName("ListarPedidos")
            .Produces<PagedResult<PedidoDto>>(200);

        group.MapGet("/{id:guid}", BuscarPorId)
            .WithName("BuscarPedido")
            .Produces<PedidoDto>(200)
            .Produces(404);

        group.MapPost("/", CriarPedido)
            .WithName("CriarPedido")
            .Produces<PedidoDto>(201)
            .Produces<ValidationProblemDetails>(400)
            .RequireAuthorization("PodeEscrever");

        group.MapDelete("/{id:guid}", CancelarPedido)
            .WithName("CancelarPedido")
            .Produces(204)
            .Produces(404)
            .RequireAuthorization("Administrador");
    }

    private static async Task<IResult> ListarPedidos(
        [AsParameters] PaginacaoQuery query,
        IPedidoService service,
        CancellationToken ct)
    {
        var resultado = await service.ListarAsync(query, ct);
        return Results.Ok(resultado);
    }

    private static async Task<IResult> BuscarPorId(
        Guid id,
        IPedidoService service,
        CancellationToken ct)
    {
        var pedido = await service.BuscarAsync(id, ct);
        return pedido is null ? Results.NotFound() : Results.Ok(pedido);
    }

    private static async Task<IResult> CriarPedido(
        CriarPedidoRequest request,
        IPedidoService service,
        CancellationToken ct)
    {
        var pedido = await service.CriarAsync(request, ct);
        return Results.CreatedAtRoute("BuscarPedido", new { id = pedido.Id }, pedido);
    }

    private static async Task<IResult> CancelarPedido(
        Guid id,
        IPedidoService service,
        CancellationToken ct)
    {
        var cancelado = await service.CancelarAsync(id, ct);
        return cancelado ? Results.NoContent() : Results.NotFound();
    }
}

Registro automático via reflection

Para não precisar registrar cada módulo manualmente em Program.cs, use scanning automático:

// Extensions/EndpointExtensions.cs
public static class EndpointExtensions
{
    public static IServiceCollection AddEndpointModules(
        this IServiceCollection services,
        Assembly assembly)
    {
        var moduleTypes = assembly
            .GetTypes()
            .Where(t => t is { IsAbstract: false, IsInterface: false }
                        && typeof(IEndpointModule).IsAssignableFrom(t));

        foreach (var type in moduleTypes)
            services.AddSingleton(typeof(IEndpointModule), type);

        return services;
    }

    public static WebApplication MapEndpointModules(this WebApplication app)
    {
        var modules = app.Services.GetRequiredService<IEnumerable<IEndpointModule>>();

        foreach (var module in modules)
            module.MapEndpoints(app);

        return app;
    }
}

// Program.cs — limpo e extensível
builder.Services.AddEndpointModules(typeof(Program).Assembly);

var app = builder.Build();
app.MapEndpointModules();
app.Run();

Criar um novo módulo agora é apenas criar uma nova classe que implementa IEndpointModule. Nenhuma alteração em Program.cs.

Validação com IValidateOptions<T> e FluentValidation

O .NET 9 trouxe melhorias no pipeline de validação. Combinando com FluentValidation, você adiciona um filtro de endpoint para validar automaticamente requests:

// Validators/CriarPedidoValidator.cs
public class CriarPedidoValidator : AbstractValidator<CriarPedidoRequest>
{
    public CriarPedidoValidator()
    {
        RuleFor(x => x.ClienteId).NotEmpty();
        RuleFor(x => x.Itens).NotEmpty().WithMessage("Pedido deve ter pelo menos um item");
        RuleFor(x => x.Itens)
            .Must(itens => itens.All(i => i.Quantidade > 0))
            .WithMessage("Quantidade deve ser maior que zero");
    }
}

// Filtro de endpoint reutilizável
public class ValidationFilter<T> : IEndpointFilter
{
    private readonly IValidator<T> _validator;

    public ValidationFilter(IValidator<T> validator) => _validator = validator;

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var request = context.Arguments
            .OfType<T>()
            .FirstOrDefault();

        if (request is null)
            return await next(context);

        var resultado = await _validator.ValidateAsync(request);

        if (!resultado.IsValid)
            return Results.ValidationProblem(resultado.ToDictionary());

        return await next(context);
    }
}

// Extension para aplicar facilmente no grupo
public static RouteGroupBuilder WithValidation<T>(this RouteHandlerBuilder builder)
    where T : class
{
    builder.AddEndpointFilter<ValidationFilter<T>>();
    return builder; // não compila — veja abaixo
}

// Uso no módulo (FluentValidation validators registrados via DI)
group.MapPost("/", CriarPedido)
    .AddEndpointFilter<ValidationFilter<CriarPedidoRequest>>()
    .Produces<PedidoDto>(201)
    .Produces<ValidationProblemDetails>(400);

Grupos aninhados: versionamento e autenticação por contexto

Grupos podem ser aninhados para aplicar configurações específicas a subconjuntos de endpoints:

public class PedidosModule : IEndpointModule
{
    public void MapEndpoints(IEndpointRouteBuilder app)
    {
        // Grupo base com autenticação
        var baseGroup = app.MapGroup("/api")
            .RequireAuthorization()
            .WithOpenApi();

        // v1: endpoints estáveis
        var v1 = baseGroup.MapGroup("/v1/pedidos")
            .WithTags("Pedidos v1");

        v1.MapGet("/", ListarPedidosV1);
        v1.MapPost("/", CriarPedidoV1);

        // v2: novos endpoints + deprecated em v1
        var v2 = baseGroup.MapGroup("/v2/pedidos")
            .WithTags("Pedidos v2");

        v2.MapGet("/", ListarPedidosV2); // resposta paginada diferente
        v2.MapPost("/", CriarPedidoV2); // aceita múltiplos itens em batch
        v2.MapPost("/batch", CriarPedidosBatchV2);

        // Endpoints públicos sem autenticação (aninhados fora do baseGroup)
        var publicGroup = app.MapGroup("/api/v1/pedidos/public")
            .WithTags("Pedidos Público")
            .AllowAnonymous();

        publicGroup.MapGet("/rastrear/{codigo}", RastrearPedido);
    }
}

AsParameters: binding de query strings como objetos

O atributo [AsParameters] (C# 11/.NET 7+) elimina a repetição de parâmetros individuais em endpoints com muitos filtros:

// Antes: parâmetros individuais — verbose e frágil
app.MapGet("/pedidos", async (
    int? pagina,
    int? tamanhoPagina,
    string? status,
    string? clienteId,
    DateTime? dataInicio,
    DateTime? dataFim,
    IPedidoService service) => { ... });

// Depois: objeto tipado com [AsParameters]
public record PedidoFiltro(
    int Pagina = 1,
    int TamanhoPagina = 20,
    string? Status = null,
    string? ClienteId = null,
    DateTime? DataInicio = null,
    DateTime? DataFim = null
);

app.MapGet("/pedidos", async (
    [AsParameters] PedidoFiltro filtro,
    IPedidoService service,
    CancellationToken ct) =>
{
    return Results.Ok(await service.ListarAsync(filtro, ct));
});

O binder do ASP.NET Core popula o record automaticamente a partir de query string, headers e route values — sem atributos extras na maioria dos casos.

TypedResults: retornos fortemente tipados

O .NET 7+ introduziu TypedResults como alternativa a Results. A diferença crítica é que o compilador consegue inferir os tipos de retorno para o Swagger sem os atributos Produces<T>:

// Com Results: metadata precisa ser declarada manualmente
app.MapGet("/pedidos/{id}", async (Guid id, IPedidoService svc) =>
{
    var pedido = await svc.BuscarAsync(id);
    return pedido is null ? Results.NotFound() : Results.Ok(pedido);
})
.Produces<PedidoDto>(200)
.Produces(404);

// Com TypedResults: Swagger gera automaticamente — sem atributos extras
app.MapGet("/pedidos/{id}", async Task<Results<Ok<PedidoDto>, NotFound>> (
    Guid id, IPedidoService svc) =>
{
    var pedido = await svc.BuscarAsync(id);
    return pedido is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(pedido);
});

O tipo de retorno Task<Results<Ok<PedidoDto>, NotFound>> documenta os possíveis retornos no contrato do método — o compilador avisa se você retornar um tipo não declarado.

Middleware vs filtros de endpoint: qual usar

Com Minimal APIs, você tem duas formas de adicionar comportamento transversal:

// Filtro de endpoint — escopo específico (por grupo ou por handler)
group.MapPost("/", CriarPedido)
    .AddEndpointFilter(async (ctx, next) =>
    {
        _logger.LogInformation("Criando pedido para {ClienteId}", ctx.GetArgument<CriarPedidoRequest>(0).ClienteId);
        var resultado = await next(ctx);
        _logger.LogInformation("Pedido criado com resultado {StatusCode}", resultado is IValueHttpResult r ? r : "?");
        return resultado;
    });

// Middleware — escopo global, mais performático para cross-cutting concerns
app.Use(async (ctx, next) =>
{
    // executado para TODOS os requests, incluindo arquivos estáticos
    await next();
});

Regra prática: use filtros de endpoint para lógica que precisa de acesso aos argumentos do handler (validação, logging de negócio). Use middleware para concerns verdadeiramente globais (rate limiting, autenticação, correlation ID).

Estrutura de projeto recomendada

A organização que funciona em projetos médios a grandes:

src/
  MinhaApi/
    Features/
      Pedidos/
        PedidosModule.cs         ← IEndpointModule
        PedidoService.cs
        PedidoDto.cs
        CriarPedidoRequest.cs
        CriarPedidoValidator.cs
      Clientes/
        ClientesModule.cs
        ...
    Infrastructure/
      Data/AppDbContext.cs
      ...
    Common/
      Filters/ValidationFilter.cs
      Extensions/EndpointExtensions.cs
      Pagination/PagedResult.cs
    Program.cs                   ← apenas bootstrap

Com essa estrutura, cada feature é um módulo fechado. Adicionar uma nova feature é adicionar um novo diretório. Remover uma feature é remover um diretório — sem efeitos colaterais em outros módulos.

Minimal APIs com grupos tipados não são uma alternativa "menor" aos controllers — são uma arquitetura explícita onde o modelo mental é simples: requests entram, handlers executam, responses saem. A organização em módulos por feature mantém a coesão sem o overhead burocrático de controllers, atributos de rota e action filters espalhados por herança.

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.