APIs mínimas em .NET: criando APIs HTTP leves
Se você já cria APIs com ASP.NET Core há algum tempo, provavelmente está familiarizado com a abordagem baseada em controlador: crie uma classe de controlador, decore-a com atributos, injete seus serviços por meio do construtor e conecte suas rotas. Funciona, e funciona bem - mas às vezes parece que você está escrevendo muita cerimônia no que equivale a “aceitar este pedido, fazer algo, retornar uma resposta”.
Esse é exatamente o problema que as APIs mínimas pretendem resolver. Introduzidas no .NET 6, as APIs mínimas permitem definir pontos de extremidade HTTP com muito pouco padrão. Sem controladores, sem atributos, sem malabarismo com classes de inicialização — apenas mapeamentos diretos de rota para manipulador em um estilo limpo e funcional.
Nesta postagem, quero orientar você sobre tudo o que você precisa saber sobre APIs mínimas: desde o primeiro endpoint até a organização de grandes aplicativos, tratamento de autenticação, validação, documentos OpenAPI e considerações de desempenho. Vamos lá.
Por que APIs mínimas?
Antes de começarmos, vamos falar sobre por que você escolheria APIs mínimas em vez da abordagem tradicional baseada em controlador.
Controladores são ótimos quando você precisa de uma estrutura estruturada e opinativa. Eles fornecem vinculação de modelo, filtros, negociação de conteúdo e uma separação clara de preocupações prontas para uso. Para aplicações empresariais de grande porte com dezenas de desenvolvedores, a consistência imposta pelos controladores pode ser um benefício real.
APIs mínimas, por outro lado, brilham quando você deseja:
- Menos clichê — sem classes de controlador, sem atributos
[ApiController], sem configuração de inicialização separada. - Inicialização mais rápida — menos operações baseadas em reflexão no momento da inicialização.
- Modelo mental mais simples — uma rota é mapeada diretamente para um manipulador. É isso.
- Otimizado para microsserviços — quando sua API tem de 5 a 10 endpoints, uma configuração completa do controlador pode parecer um exagero.
A boa notícia é que esta não é uma decisão de um ou outro. Você pode misturar controladores e APIs mínimas no mesmo projeto. Mas quando você se sentir confortável com a abordagem mínima, poderá buscá-la com mais frequência do que esperava.
Primeiros passos
Vamos criar uma API Minimal do zero. Se você tiver o SDK do .NET instalado, é tão simples quanto:
dotnet new web -n MyMinimalApi
cd MyMinimalApi
Isso fornece um Program.cs parecido com isto:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();
É isso. Essa é uma API funcional. Não Startup.cs, nenhuma classe de controlador, nenhuma configuração de roteamento. Você executa dotnet run, pressiona http://localhost:5000 e obtém “Olá, mundo!” voltar. A simplicidade aqui é o ponto principal.
Vamos torná-lo um pouco mais útil com uma API de tarefas clássica:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var todos = new List<Todo>();
app.MapGet("/todos", () => todos);
app.MapGet("/todos/{id:int}", (int id) =>
todos.FirstOrDefault(t => t.Id == id) is { } todo
? Results.Ok(todo)
: Results.NotFound());
app.MapPost("/todos", (Todo todo) =>
{
todos.Add(todo);
return Results.Created($"/todos/{todo.Id}", todo);
});
app.MapDelete("/todos/{id:int}", (int id) =>
{
var todo = todos.FirstOrDefault(t => t.Id == id);
if (todo is null) return Results.NotFound();
todos.Remove(todo);
return Results.NoContent();
});
app.Run();
record Todo(int Id, string Title, bool IsComplete);
Temos CRUD completo em menos de 30 linhas. Esse é o poder da abordagem mínima.
Manipuladores de rota
Manipuladores de rota são as funções executadas quando uma solicitação corresponde a uma rota. Você tem várias opções para defini-los.
Expressões Lambda
A abordagem mais comum e o que você verá na maioria dos exemplos:
app.MapGet("/hello", () => "Hello!");
app.MapGet("/hello/{name}", (string name) => $"Hello, {name}!");
Grupos de métodos
Para uma lógica mais complexa, você pode apontar para um método nomeado:
app.MapGet("/products", GetProducts);
app.MapPost("/products", CreateProduct);
static IResult GetProducts(AppDbContext db)
=> Results.Ok(db.Products.ToList());
static IResult CreateProduct(Product product, AppDbContext db)
{
db.Products.Add(product);
db.SaveChanges();
return Results.Created($"/products/{product.Id}", product);
}
```Esta é minha abordagem preferida para qualquer coisa além de uma linha. Ele mantém sua seção de mapeamento de rotas limpa e legível – você pode ver rapidamente quais pontos de extremidade existem sem se aprofundar nos detalhes de implementação.
### Funções locais
Você também pode usar funções locais, o que é útil quando você deseja manter os manipuladores próximos às suas definições de rota:
```csharp
app.MapGet("/health", CheckHealth);
IResult CheckHealth()
{
// Some health check logic
return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow });
}
Associação de parâmetros
Uma das coisas que realmente aprecio nas APIs mínimas é como a vinculação de parâmetros é intuitiva. A estrutura descobre de onde extrair valores com base no contexto e no tipo.
Parâmetros de rota
app.MapGet("/users/{id:int}", (int id) => $"User {id}");
app.MapGet("/files/{*path}", (string path) => $"File: {path}"); // catch-all
Parâmetros de string de consulta
app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) =>
Results.Ok(new { query, page, pageSize }));
Tipos anuláveis tornam-se parâmetros opcionais. Os valores padrão funcionam exatamente como você espera.
Corpo da solicitação
app.MapPost("/orders", (Order order) =>
{
// 'order' is automatically deserialized from the JSON body
return Results.Created($"/orders/{order.Id}", order);
});
Cabeçalho e vinculação de serviço
app.MapGet("/protected", (
[FromHeader(Name = "X-Api-Key")] string apiKey,
[FromServices] ILogger<Program> logger) =>
{
logger.LogInformation("Request with API key: {Key}", apiKey[..4] + "****");
return Results.Ok("Authorized");
});
HttpContext e HttpRequest
Quando você precisar de acesso de nível inferior:
app.MapGet("/info", (HttpContext context) =>
{
var userAgent = context.Request.Headers.UserAgent.ToString();
var ip = context.Connection.RemoteIpAddress?.ToString();
return Results.Ok(new { userAgent, ip });
});
Vinculação personalizada com BindAsync
Para tipos complexos, você pode implementar um método estático BindAsync:
public record PaginationParams(int Page, int PageSize)
{
public static ValueTask<PaginationParams?> BindAsync(HttpContext context)
{
int.TryParse(context.Request.Query["page"], out var page);
int.TryParse(context.Request.Query["pageSize"], out var pageSize);
var result = new PaginationParams(
Page: page > 0 ? page : 1,
PageSize: pageSize > 0 ? Math.Min(pageSize, 100) : 20
);
return ValueTask.FromResult<PaginationParams?>(result);
}
}
app.MapGet("/items", (PaginationParams pagination, AppDbContext db) =>
{
var items = db.Items
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToList();
return Results.Ok(items);
});
Isso é incrivelmente poderoso. Você define a lógica de ligação uma vez e a reutiliza em todos os seus endpoints.
Validação
Uma área em que as APIs mínimas não oferecem tanta coisa pronta para uso em comparação com os controladores é a validação de modelo. Não há aplicação automática de [Required] ou [StringLength]. Mas existem padrões claros para lidar com isso.
Validação manual
A abordagem mais simples — basta validar no manipulador:
app.MapPost("/users", (CreateUserRequest request) =>
{
if (string.IsNullOrWhiteSpace(request.Email))
return Results.BadRequest(new { Error = "Email is required" });
if (request.Name?.Length > 100)
return Results.BadRequest(new { Error = "Name must be 100 characters or less" });
// Create user...
return Results.Created($"/users/{request.Email}", request);
});
Usando uma biblioteca de validação
Para qualquer coisa não trivial, recomendo usar FluentValidation ou uma biblioteca semelhante:
public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Age).InclusiveBetween(0, 150);
}
}
Você pode conectar isso aos seus endpoints por meio de filtros de endpoint, o que nos leva à próxima seção.
Filtros de endpoint
Os filtros de endpoint são um dos melhores recursos das APIs mínimas. Pense neles como middleware, mas com escopo para endpoints específicos, em vez de todo o pipeline. Eles foram introduzidos no .NET 7 e são fantásticos para questões transversais.
Filtro Básico
app.MapPost("/todos", (Todo todo) =>
{
// Handle the request
return Results.Created($"/todos/{todo.Id}", todo);
})
.AddEndpointFilter(async (context, next) =>
{
var todo = context.GetArgument<Todo>(0);
if (string.IsNullOrEmpty(todo.Title))
{
return Results.BadRequest(new { Error = "Title is required" });
}
return await next(context);
});
Filtro de validação com FluentValidation
É aqui que tudo se torna realmente poderoso – um filtro de validação reutilizável:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var model = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (model is null)
return Results.BadRequest(new { Error = "Request body is required" });
var result = await _validator.ValidateAsync(model);
if (!result.IsValid)
{
var errors = result.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return Results.ValidationProblem(errors);
}
return await next(context);
}
}
// Usage
app.MapPost("/users", (CreateUserRequest request) =>
{
// Only reached if validation passes
return Results.Created($"/users/{request.Email}", request);
})
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();
Filtro de registro
app.MapGet("/products", (AppDbContext db) => db.Products.ToList())
.AddEndpointFilter(async (context, next) =>
{
var logger = context.HttpContext
.RequestServices.GetRequiredService<ILogger<Program>>();
var sw = Stopwatch.StartNew();
var result = await next(context);
sw.Stop();
logger.LogInformation("Endpoint executed in {Elapsed}ms", sw.ElapsedMilliseconds);
return result;
});
Você pode encadear vários filtros e eles são executados na ordem em que são adicionados — assim como o middleware.
Integração OpenAPI/Swagger
Uma boa documentação de API não é mais opcional. Felizmente, as APIs Minimal têm suporte de primeira classe para OpenAPI por meio do pacote Microsoft.AspNetCore.OpenApi.
Configuração básica
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();
app.MapGet("/todos", () => new List<Todo>())
.WithName("GetTodos")
.WithDescription("Retrieves all todo items")
.WithTags("Todos");
app.Run();
Metadados avançados de endpoint
Você pode fornecer informações detalhadas sobre seus endpoints:
app.MapPost("/todos", (Todo todo) =>
{
return Results.Created($"/todos/{todo.Id}", todo);
})
.WithName("CreateTodo")
.WithDescription("Creates a new todo item")
.WithTags("Todos")
.Accepts<Todo>("application/json")
.Produces<Todo>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.ProducesValidationProblem();
Usando WithOpenApi para personalização
Para controle refinado sobre o documento OpenAPI gerado:
app.MapGet("/todos/{id:int}", (int id) => Results.Ok(new Todo(id, "Sample", false)))
.WithOpenApi(operation =>
{
operation.Summary = "Get a specific todo";
operation.Description = "Retrieves a single todo item by its unique identifier.";
operation.Parameters[0].Description = "The unique identifier of the todo item";
return operation;
});
Isso oferece o mesmo nível de controle de documentação que você obteria com os comentários XML do Swashbuckle nos controladores, mas de uma forma mais explícita, que prioriza o código.
Autenticação e Autorização
A proteção de endpoints mínimos da API segue os mesmos padrões do restante do ASP.NET Core — basta aplicá-los de maneira diferente.
Configuração básica```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = “https://your-identity-provider.com”; options.Audience = “your-api”; });
builder.Services.AddAuthorizationBuilder() .AddPolicy(“AdminOnly”, policy => policy.RequireRole(“Admin”)) .AddPolicy(“PremiumUser”, policy => policy.RequireClaim(“subscription”, “premium”));
var app = builder.Build();
app.UseAuthentication(); app.UseAuthorization();
### Aplicando autorização a endpoints
```csharp
// Require any authenticated user
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
Name = user.FindFirstValue(ClaimTypes.Name),
Email = user.FindFirstValue(ClaimTypes.Email)
});
}).RequireAuthorization();
// Require a specific policy
app.MapDelete("/admin/users/{id}", (int id) =>
{
// Delete user logic
return Results.NoContent();
}).RequireAuthorization("AdminOnly");
// Allow anonymous access
app.MapGet("/public/health", () => Results.Ok(new { Status = "Healthy" }))
.AllowAnonymous();
Observe como você pode injetar ClaimsPrincipal diretamente nos parâmetros do seu manipulador — a estrutura cuida do resto. Essa é uma daquelas pequenas coisas que fazem as APIs Minimal parecerem realmente elegantes.
Organizando APIs grandes
O elefante na sala com APIs mínimas é a organização. Quando seu Program.cs tem 50 endpoints, fica uma bagunça. Aqui estão os padrões que uso para manter as coisas gerenciáveis.
Grupos de rotas
Os grupos de rotas (introduzidos no .NET 7) permitem compartilhar configurações entre endpoints relacionados:
var todos = app.MapGroup("/todos")
.WithTags("Todos")
.RequireAuthorization();
todos.MapGet("/", (AppDbContext db) => db.Todos.ToListAsync());
todos.MapGet("/{id:int}", (int id, AppDbContext db) => db.Todos.FindAsync(id));
todos.MapPost("/", (Todo todo, AppDbContext db) =>
{
db.Todos.Add(todo);
db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
});
Todos os endpoints do grupo compartilham o prefixo /todos, a tag Todos e o requisito de autorização. Limpar.
Métodos de extensão
Este é o padrão que realmente aumenta. Mova cada grupo de endpoints para sua própria classe estática:
// Endpoints/TodoEndpoints.cs
public static class TodoEndpoints
{
public static RouteGroupBuilder MapTodoEndpoints(this WebApplication app)
{
var group = app.MapGroup("/todos").WithTags("Todos");
group.MapGet("/", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create);
group.MapPut("/{id:int}", Update);
group.MapDelete("/{id:int}", Delete);
return group;
}
private static async Task<IResult> GetAll(AppDbContext db)
=> Results.Ok(await db.Todos.ToListAsync());
private static async Task<IResult> GetById(int id, AppDbContext db)
=> await db.Todos.FindAsync(id) is { } todo
? Results.Ok(todo)
: Results.NotFound();
private static async Task<IResult> Create(Todo todo, AppDbContext db)
{
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
}
private static async Task<IResult> Update(int id, Todo updated, AppDbContext db)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
todo.Title = updated.Title;
todo.IsComplete = updated.IsComplete;
await db.SaveChangesAsync();
return Results.Ok(todo);
}
private static async Task<IResult> Delete(int id, AppDbContext db)
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return Results.NoContent();
}
}
// Program.cs — stays clean
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapTodoEndpoints();
app.MapUserEndpoints();
app.MapOrderEndpoints();
app.Run();
Seu Program.cs se torna um índice para sua API. Cada grupo de endpoints reside em seu próprio arquivo. Esta é a abordagem que recomendo para aplicações de produção.
Biblioteca Carter
Se você quiser ainda mais estrutura, a biblioteca Carter oferece uma abordagem baseada em módulos:
public class TodoModule : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/todos", async (AppDbContext db) =>
await db.Todos.ToListAsync());
app.MapPost("/todos", async (Todo todo, AppDbContext db) =>
{
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/todos/{todo.Id}", todo);
});
}
}
Carter descobre e registra automaticamente todos os módulos. É um bom meio-termo entre a abordagem bruta da API Mínima e os controladores completos.
TypedResults e tipos de resposta
A partir do .NET 7, você pode usar TypedResults em vez de Results para respostas com segurança de tipo. Isso pode parecer uma pequena mudança, mas traz benefícios reais para a documentação e testabilidade do OpenAPI.
app.MapGet("/todos/{id:int}", async Task<Results<Ok<Todo>, NotFound>> (int id, AppDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
return todo is not null
? TypedResults.Ok(todo)
: TypedResults.NotFound();
});
O tipo de retorno Results<Ok<Todo>, NotFound> informa explicitamente à estrutura (e aos seus documentos OpenAPI) exatamente quais tipos de resposta este endpoint pode produzir. Chega de suposições, chega de chamadas manuais Produces<>() para casos básicos.
Para vários resultados possíveis:
app.MapPost("/todos",
async Task<Results<Created<Todo>, ValidationProblem, Conflict>>
(Todo todo, AppDbContext db) =>
{
if (string.IsNullOrEmpty(todo.Title))
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
{ "Title", new[] { "Title is required" } }
});
if (await db.Todos.AnyAsync(t => t.Title == todo.Title))
return TypedResults.Conflict();
db.Todos.Add(todo);
await db.SaveChangesAsync();
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
Comecei a usar TypedResults em todos os novos projetos. O compilador detecta incompatibilidades entre os tipos de retorno declarados e o que você realmente retorna, o que elimina uma classe inteira de surpresas em tempo de execução.
Considerações de desempenho
Um dos pontos de venda das APIs Minimal é o desempenho, e vale a pena entender por que elas são mais rápidas.
Sobrecarga de inicialização reduzida. Os controladores dependem muito da reflexão para descobrir endpoints, vincular modelos e aplicar filtros. APIs mínimas usam geradores de origem (a partir do .NET 7) para gerar código de ligação em tempo de compilação. Isso significa menos trabalho na inicialização e menos alocação de memória por solicitação.
Sem pipeline MVC. APIs baseadas em controlador passam por todo o pipeline MVC: seleção de ação, vinculação de modelo, filtros de ação, execução de resultados. APIs mínimas ignoram tudo isso e vão direto do roteamento para o seu manipulador.
Compilação RequestDelegate. A estrutura compila suas expressões lambda em instâncias RequestDelegate otimizadas. O código resultante é muito próximo do que você escreveria à mão se estivesse trabalhando diretamente com HttpContext.
Aqui estão algumas dicas práticas para maximizar o desempenho:
// Use AsNoTracking for read-only queries
app.MapGet("/products", async (AppDbContext db) =>
await db.Products.AsNoTracking().ToListAsync());
// Return results directly — avoid unnecessary allocations
app.MapGet("/count", async (AppDbContext db) =>
await db.Products.CountAsync());
// Use cancellation tokens for long-running operations
app.MapGet("/report", async (AppDbContext db, CancellationToken ct) =>
await db.Orders
.AsNoTracking()
.GroupBy(o => o.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync(ct));
```Também vale a pena mencionar que a lacuna de desempenho entre controladores e APIs mínimas continua diminuindo a cada versão do .NET. Para a maioria dos aplicativos, a diferença não será o gargalo, mas sim as consultas ao banco de dados e as chamadas de serviço externas. Escolha com base na experiência do desenvolvedor e nas necessidades do projeto, não em benchmarks.
## Conclusão
As APIs mínimas percorreram um longo caminho desde sua introdução no .NET 6. O que começou como um recurso de demonstração do tipo "olá mundo" amadureceu e se tornou uma escolha legítima para APIs de produção. Com filtros de endpoint, grupos de rotas, resultados digitados e suporte sólido a OpenAPI, você tem tudo o que precisa para criar serviços bem estruturados e de fácil manutenção.
Minha recomendação? Se você estiver iniciando um novo projeto de API — especialmente um microsserviço ou uma API interna focada — experimente seriamente as APIs Minimal. Use o padrão de método de extensão para organização, conte com filtros de endpoint para questões transversais e aproveite `TypedResults` para segurança de tipo.
Para projetos existentes baseados em controlador, não há pressa para migrar. Ambas as abordagens funcionam bem e você pode até usá-las lado a lado. Mas da próxima vez que você precisar adicionar um pequeno serviço ou uma API interna rápida, ignore os controladores e vá para o mínimo. Você pode não voltar.
Boa codificação!