.NET Carter Minimal APIs ASP.NET Core Arquitetura Boas Práticas

Carter no .NET: Minimal APIs modulares sem boilerplate de Controllers

Guia prático do Carter para .NET: ICarterModule para organização de endpoints por feature, validação com FluentValidation integrada.

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

Minimal APIs no .NET são excelentes para performance e simplicidade — mas quando o projeto cresce, registrar 50 endpoints no Program.cs vira um arquivo de 500 linhas difícil de navegar. Controllers resolvem a organização, mas trazem de volta o overhead de MVC. Carter é o meio-termo: mantém a performance e o modelo de Minimal APIs, mas adiciona estrutura modular por feature através da interface ICarterModule.

O que o Carter resolve

O problema central das Minimal APIs em projetos médios e grandes não é técnico — é organizacional. app.MapGet("/api/orders/{id}", ...) espalhado em um único arquivo não escala. Você pode extrair para métodos estáticos em arquivos separados, mas aí está reinventando o Carter.

Carter oferece: ICarterModule como contrato de módulo autodescoberto por DI, organização natural por feature, integração com FluentValidation via context.Validate(), e compatibilidade total com todas as funcionalidades de Minimal APIs (OpenAPI, auth, rate limiting, output cache).

Setup

dotnet add package Carter
// Program.cs — tudo que você precisa para habilitar o Carter
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCarter();  // descobre ICarterModule automaticamente via DI

// Serviços normais
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

var app = builder.Build();

app.MapCarter();  // registra todos os módulos descobertos

app.Run();

ICarterModule: um módulo por feature

// Features/Orders/OrdersModule.cs
// Toda a lógica de roteamento de pedidos em um único lugar

public class OrdersModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/orders")
            .WithTags("Orders")
            .RequireAuthorization();  // aplica auth em todos os endpoints do módulo

        group.MapGet("/", GetOrders)
            .WithName("GetOrders")
            .WithSummary("Lista pedidos paginados");

        group.MapGet("/{id:guid}", GetOrderById)
            .WithName("GetOrder")
            .WithSummary("Retorna um pedido pelo ID")
            .Produces<OrderResponse>()
            .ProducesProblem(404);

        group.MapPost("/", CreateOrder)
            .WithName("CreateOrder")
            .WithSummary("Cria um novo pedido")
            .Produces<CreateOrderResponse>(201)
            .ProducesValidationProblem();

        group.MapPatch("/{id:guid}/cancel", CancelOrder)
            .WithName("CancelOrder")
            .WithSummary("Cancela um pedido");

        group.MapDelete("/{id:guid}", DeleteOrder)
            .WithName("DeleteOrder")
            .RequireAuthorization("admin");  // policy específica por endpoint
    }

    // Handlers como métodos estáticos do módulo — sem instância desnecessária
    private static async Task<IResult> GetOrders(
        [AsParameters] ListOrdersRequest request,
        IOrderRepository repository,
        CancellationToken ct)
    {
        var orders = await repository.ListAsync(request.Page, request.PageSize, ct);
        return Results.Ok(orders);
    }

    private static async Task<IResult> GetOrderById(
        Guid id,
        IOrderRepository repository,
        CancellationToken ct)
    {
        var order = await repository.GetByIdAsync(id, ct);
        return order is null
            ? Results.NotFound(new { Message = $"Pedido {id} não encontrado" })
            : Results.Ok(order);
    }

    private static async Task<IResult> CreateOrder(
        CreateOrderRequest request,
        IValidator<CreateOrderRequest> validator,
        IOrderRepository repository,
        HttpContext httpContext,
        CancellationToken ct)
    {
        // Validação integrada com FluentValidation — retorna 422 automaticamente
        var validation = await validator.ValidateAsync(request, ct);
        if (!validation.IsValid)
            return Results.ValidationProblem(validation.ToDictionary());

        var order = await repository.CreateAsync(request, ct);
        return Results.Created($"/api/orders/{order.Id}", new CreateOrderResponse(order.Id, order.Total));
    }

    private static async Task<IResult> CancelOrder(
        Guid id,
        IOrderRepository repository,
        CancellationToken ct)
    {
        var success = await repository.CancelAsync(id, ct);
        return success
            ? Results.NoContent()
            : Results.NotFound();
    }

    private static async Task<IResult> DeleteOrder(
        Guid id,
        IOrderRepository repository,
        CancellationToken ct)
    {
        await repository.DeleteAsync(id, ct);
        return Results.NoContent();
    }
}

Validação com FluentValidation

// Features/Orders/CreateOrderValidator.cs
// Validador que vive junto com o módulo

public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator(IProductRepository products)
    {
        RuleFor(x => x.CustomerId).NotEmpty();

        RuleFor(x => x.Items)
            .NotEmpty()
            .WithMessage("O pedido deve ter ao menos um item");

        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(i => i.ProductId).NotEmpty();
            item.RuleFor(i => i.Quantity).GreaterThan(0);
            item.RuleFor(i => i.UnitPrice).GreaterThan(0);
        });

        // Validação assíncrona: verifica se o cliente existe
        RuleFor(x => x.CustomerId)
            .MustAsync(async (id, ct) =>
                await products.CustomerExistsAsync(id, ct))
            .WithMessage("Cliente não encontrado")
            .When(x => x.CustomerId != Guid.Empty);
    }
}

// Helper de extensão para usar com Carter
public static class ValidationExtensions
{
    public static IResult ToValidationProblem(
        this ValidationResult result) =>
        Results.ValidationProblem(result.ToDictionary());
}

Módulo de autenticação com fluxo específico

// Features/Auth/AuthModule.cs
// Módulo sem auth (endpoints públicos de login/refresh)

public class AuthModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/auth")
            .WithTags("Authentication")
            .AllowAnonymous();

        group.MapPost("/login", Login)
            .WithSummary("Autentica usuário e retorna JWT")
            .Produces<LoginResponse>()
            .ProducesProblem(401);

        group.MapPost("/refresh", RefreshToken)
            .WithSummary("Renova o access token via refresh token")
            .Produces<LoginResponse>()
            .ProducesProblem(401);

        group.MapPost("/logout", Logout)
            .WithSummary("Invalida o refresh token")
            .RequireAuthorization();  // logout requer estar autenticado
    }

    private static async Task<IResult> Login(
        LoginRequest request,
        IAuthService authService,
        CancellationToken ct)
    {
        var result = await authService.LoginAsync(request.Email, request.Password, ct);
        return result is null
            ? Results.Problem("Credenciais inválidas", statusCode: 401)
            : Results.Ok(result);
    }

    // ...
}

Organização de projeto com Carter

src/
├── Features/
│   ├── Orders/
│   │   ├── OrdersModule.cs           ← ICarterModule com todos os endpoints
│   │   ├── CreateOrderRequest.cs     ← DTOs de request/response
│   │   ├── CreateOrderValidator.cs   ← FluentValidation
│   │   └── OrderResponse.cs
│   ├── Products/
│   │   ├── ProductsModule.cs
│   │   ├── CreateProductRequest.cs
│   │   └── CreateProductValidator.cs
│   └── Auth/
│       ├── AuthModule.cs
│       └── LoginRequest.cs
├── Domain/
│   ├── Order.cs
│   └── Product.cs
├── Infrastructure/
│   ├── Repositories/
│   └── Persistence/
└── Program.cs                        ← apenas AddCarter() + MapCarter()

Testes com WebApplicationFactory

// Tests/Features/Orders/OrdersModuleTests.cs
public class OrdersModuleTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public OrdersModuleTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Substitui repositório real por mock em memória
                services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
            });
        }).CreateClient();
    }

    [Fact]
    public async Task GetOrderById_ExistingOrder_ReturnsOk()
    {
        // Arrange
        var createResponse = await _client.PostAsJsonAsync("/api/orders",
            new CreateOrderRequest(
                CustomerId: Guid.NewGuid(),
                Items: [new(Guid.NewGuid(), 2, 50.0m)]));
        var created = await createResponse.Content.ReadFromJsonAsync<CreateOrderResponse>();

        // Act
        var response = await _client.GetAsync($"/api/orders/{created!.OrderId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var order = await response.Content.ReadFromJsonAsync<OrderResponse>();
        order!.Id.Should().Be(created.OrderId);
    }

    [Fact]
    public async Task CreateOrder_InvalidRequest_Returns422()
    {
        var response = await _client.PostAsJsonAsync("/api/orders",
            new CreateOrderRequest(CustomerId: Guid.Empty, Items: []));

        response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
    }
}

Carter vs Controllers vs Minimal APIs puras

Aspecto Minimal APIs puras Carter MVC Controllers
Organização Manual (arquivos/métodos) ✅ ICarterModule por feature ✅ Classes por recurso
Performance ✅ Máxima ✅ Igual Minimal APIs ⚠️ Overhead MVC pipeline
Validação integrada Manual ✅ FluentValidation nativo ⚠️ [ApiController] + DataAnnotations
OpenAPI ✅ Nativo ✅ Nativo ✅ Nativo
Curva de aprendizado Baixa Baixa Média
Autodiscovery de módulos

Carter é a escolha natural para quem quer a performance das Minimal APIs com a organização dos Controllers — sem o peso do pipeline MVC. Para projetos pequenos com poucos endpoints, Minimal APIs puras são suficientes. Para projetos com muitos recursos e equipes maiores, Carter mantém o código navegável sem sacrificar nenhuma funcionalidade moderna do ASP.NET Core.


A escolha da estrutura da API impacta a produtividade do time a longo prazo. Se você quer definir os padrões de arquitetura do seu projeto .NET, a Neryx pode ajudar.

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.