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.