A Livraria já sabe guardar produtos em três bancos. Agora ela vai atender pedidos que chegam pela rede, e para isso vamos entender o que acontece entre o byte que entra no socket e a linha de código que lê o estoque.
Da linha de comando ao servidor HTTP
Até aqui quem conversava com a Livraria era o Livraria.Cli: um processo que nasce, faz uma operação e termina. Um servidor web é outra coisa. Ele sobe, fica escutando uma porta por dias ou semanas e atende milhares de requisições ao mesmo tempo, e cada requisição precisa de uma parte do estado (um DbContext, por exemplo) só para ela. Tudo o que vimos em "Um repositório, três bancos: injeção de dependência e a Livraria independente de infraestrutura" foi escolhido pensando nesse cenário. O escopo que criávamos à mão com CreateAsyncScope no CLI agora vai ser criado pelo framework uma vez por requisição.
O ASP.NET Core é o framework web do .NET. Ele tem três peças principais:
- O Kestrel é o servidor HTTP escrito em C#, multiplataforma e rápido o bastante para ficar exposto direto na internet. Na prática, costuma ficar atrás de um proxy reverso como nginx, YARP ou o balanceador de uma nuvem.
- O host junta configuração, logging, injeção de dependência e o ciclo de vida da aplicação.
- O pipeline de middleware é a sequência de funções pela qual cada requisição passa até virar uma resposta.
Criamos o projeto novo ao lado dos outros:
dotnet new web -n Livraria.Api -o src/Livraria.Api
dotnet sln add src/Livraria.Api
dotnet add src/Livraria.Api reference src/Livraria.Aplicacao src/Livraria.Dados.Postgres src/Livraria.Dados.MySql src/Livraria.Dados.Mongo
O arquivo .csproj gerado começa com <Project Sdk="Microsoft.NET.Sdk.Web">. Esse SDK diferente é o que traz a referência ao framework compartilhado Microsoft.AspNetCore.App, então não é preciso instalar nenhum pacote NuGet para ter servidor, roteamento e JSON.
O menor programa possível
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Livraria no ar");
app.Run();
São quatro linhas, e cada uma esconde bastante coisa. WebApplication.CreateBuilder monta um host com padrões sensatos:
- Configuração lida em camadas, nesta ordem:
appsettings.json, depoisappsettings.{Ambiente}.json, depois os user secrets (só em Development), depois as variáveis de ambiente e por fim os argumentos de linha de comando. A última fonte vence. - Logging para o console.
- Um
IServiceCollectionvazio, pronto para receber registros. - O Kestrel configurado.
A aplicação tem duas fases bem separadas. Enquanto temos o builder, registramos serviços em builder.Services. Depois de builder.Build(), a coleção de serviços fica somente leitura, e o objeto app serve para montar o pipeline e mapear os endpoints. Tentar registrar um serviço depois do Build lança exceção. É o mesmo princípio do BuildServiceProvider de "Um Repositório, Três Bancos": primeiro se descreve o grafo e depois ele é congelado.
app.Run() bloqueia a thread principal e fica atendendo até o processo receber um sinal de parada (Ctrl+C, SIGTERM de um contêiner). Nesse momento o host inicia um graceful shutdown: para de aceitar conexões novas e dá um tempo para as requisições em andamento terminarem.
O pipeline: uma fila de delegates
No coração do ASP.NET Core há um tipo muito simples:
public delegate Task RequestDelegate(HttpContext context);
Uma requisição inteira cabe em um HttpContext, que reúne Request, Response, User, RequestServices e mais algumas propriedades. Processar uma requisição é chamar um RequestDelegate. Um middleware é uma função que recebe o próximo delegate e devolve um novo delegate que o envolve. Quem leu "Delegates, lambdas e eventos" vai reconhecer o padrão: é composição de funções, montada uma única vez na inicialização.
O resultado lembra uma boneca russa:
requisição → [Tratamento de exceções
[HTTPS redirect
[Arquivos estáticos
[Autenticação
[Autorização
[Endpoint: seu código]
]
]
]
]
] → resposta
Cada camada pode agir na ida, na volta ou nas duas. Também pode interromper o caminho sem chamar o próximo, o que se chama short-circuit. O middleware de arquivos estáticos faz isso: quando encontra o arquivo, responde e as camadas internas nunca ficam sabendo da requisição.
Este middleware inline mede quanto tempo a requisição levou:
using System.Diagnostics;
app.Use(async (HttpContext contexto, RequestDelegate proximo) =>
{
var relogio = Stopwatch.StartNew();
contexto.Response.OnStarting(() =>
{
contexto.Response.Headers["X-Tempo-Ms"] =
relogio.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
await proximo(contexto);
});
O detalhe que costuma pegar quem está começando é o OnStarting. Quando o primeiro byte do corpo da resposta sai pela rede, os cabeçalhos já foram enviados e não podem mais mudar. Se escrevêssemos o cabeçalho depois de await proximo(contexto), o endpoint provavelmente já teria começado a escrever o JSON, e a tentativa lançaria InvalidOperationException ("Headers are read-only, response has already started"). OnStarting registra um callback que roda no último instante em que os cabeçalhos ainda podem ser alterados.
Prefira a sobrecarga de Use que recebe (HttpContext, RequestDelegate). A versão antiga recebia um Func<Task> e alocava uma closure por requisição.
app.Run(...) registra um middleware terminal, que nunca chama o próximo. Se ele aparecer antes de outros middlewares, esses outros nunca vão rodar. A ordem de registro é a ordem de execução, e ela importa de verdade. A sequência recomendada pela documentação é esta:
- Tratamento de exceções (
UseExceptionHandler), o primeiro, para envolver todo o resto. - HSTS e redirecionamento para HTTPS.
- Arquivos estáticos.
- Roteamento, que o
WebApplicationinsere sozinho. - CORS.
- Autenticação e em seguida autorização.
- Middlewares próprios que dependem do usuário autenticado.
- Endpoints.
Se a autorização vier antes da autenticação, ela vai avaliar um usuário anônimo e negar tudo. Voltaremos a isso em "Autenticação e autorização com JWT".
Middleware como classe e a armadilha dos tempos de vida
Quando a lógica cresce, o middleware vira uma classe. A convenção é um construtor que recebe o RequestDelegate e um método InvokeAsync(HttpContext, ...):
public sealed class IdDeCorrelacao(RequestDelegate proximo)
{
private const string Cabecalho = "X-Correlation-Id";
public async Task InvokeAsync(HttpContext contexto, ILogger<IdDeCorrelacao> log)
{
var id = contexto.Request.Headers[Cabecalho].FirstOrDefault()
?? contexto.TraceIdentifier;
contexto.Response.Headers[Cabecalho] = id;
using (log.BeginScope(new Dictionary<string, object> { ["CorrelationId"] = id }))
{
await proximo(contexto);
}
}
}
// no Program.cs
app.UseMiddleware<IdDeCorrelacao>();
Aqui podemos escrever o cabeçalho antes de chamar o próximo sem problema, porque a resposta ainda não começou.
A regra de tempo de vida é a seguinte. Um middleware baseado em convenção é instanciado uma vez e vive enquanto a aplicação viver, então na prática é um singleton. Se ele pedir um IRepositorioProdutos no construtor, vai capturar o repositório (e o DbContext junto) da primeira requisição e reutilizá-lo em todas as outras. É exatamente a captive dependency que o validateScopes de "Um Repositório, Três Bancos" existe para detectar. Dependências com escopo entram como parâmetros do InvokeAsync, que o framework resolve a partir do escopo da requisição em curso.
Existe também a alternativa IMiddleware: a classe implementa a interface, é registrada no contêiner com o tempo de vida que você quiser e é resolvida a cada requisição. É mais explícita e um pouco mais cara.
Roteamento e endpoints
Um middleware olha para todas as requisições. Um endpoint é o destino de uma requisição específica: um par formado por um padrão de rota e um delegate, acompanhado de metadados. O roteamento acontece em dois tempos. O UseRouting escolhe o endpoint pelo caminho e pelo verbo HTTP e o grava no HttpContext. O UseEndpoints executa o endpoint escolhido. O WebApplication insere os dois automaticamente, o primeiro no início e o segundo no fim do pipeline, e é por isso que o programa mínimo funciona sem chamá-los.
Essa separação tem uma consequência prática. Qualquer middleware registrado depois do roteamento já sabe qual endpoint vai rodar e pode ler os metadados dele. É assim que a autorização descobre se aquele endpoint exige login:
app.Use(async (contexto, proximo) =>
{
var endpoint = contexto.GetEndpoint();
if (endpoint?.Metadata.GetMetadata<SomenteLeituraAttribute>() is not null
&& !HttpMethods.IsGet(contexto.Request.Method))
{
contexto.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
return;
}
await proximo(contexto);
});
Os padrões de rota aceitam parâmetros e restrições:
app.MapGet("/produtos/{id:int}", ...); // só casa com inteiros
app.MapGet("/produtos/isbn/{isbn:length(13)}", ...);
app.MapGet("/arquivos/{**caminho}", ...); // catch-all
As restrições de rota servem para desambiguar rotas, não para validar dados. GET /produtos/abc com {id:int} devolve 404 porque nenhuma rota casou, e não 400. A validação de verdade é o assunto de "Validação, ProblemDetails e OpenAPI".
Binding: de onde vem cada argumento
O que torna as Minimal APIs agradáveis é que o delegate do endpoint é uma função comum, com parâmetros tipados, e o framework descobre de onde tirar cada um:
app.MapGet("/produtos/{id:int}", async (
int id, // valor da rota
IRepositorioProdutos repositorio, // serviço registrado no contêiner
CancellationToken ct) => // HttpContext.RequestAborted
{
var produto = await repositorio.ObterAsync(id, ct);
return produto is null ? Results.NotFound() : Results.Ok(produto);
});
As regras de inferência, em ordem de prioridade:
- Tipos especiais são injetados diretamente:
HttpContext,HttpRequest,HttpResponse,ClaimsPrincipaleCancellationToken. - Um nome que aparece no padrão da rota vem da rota.
- Um tipo registrado no contêiner vem do contêiner.
- Tipos simples (números,
string,Guid,DateOnly, qualquer tipo comTryParse) vêm da query string. - Um tipo complexo em POST, PUT ou PATCH vem do corpo, desserializado de JSON.
Quando a inferência não basta, os atributos resolvem: [FromRoute], [FromQuery], [FromHeader(Name = "X-Api-Versao")], [FromBody], [FromServices], e [AsParameters], que agrupa vários parâmetros numa classe ou record.
O CancellationToken merece atenção. Ele é cancelado quando o cliente desiste, por exemplo ao fechar a aba ou quando o timeout do proxy estoura. Repassá-lo até o ToListAsync do EF Core e até o driver do Mongo faz a consulta ser abortada no banco em vez de rodar para ninguém. É o mesmo raciocínio de "Assincronia Avançada", agora ligado de ponta a ponta sem esforço.
O código acima devolve a entidade Produto diretamente como JSON. Funciona, mas é um erro de projeto que vamos corrigir no próximo artigo.
Reaproveitando a infraestrutura de "Um Repositório, Três Bancos"
No Livraria.Cli, a escolha do banco vinha da variável LIVRARIA_PROVEDOR. Na API ela passa a vir da configuração, que já sabe ler variáveis de ambiente:
// appsettings.Development.json
{
"Livraria": { "Provedor": "postgres" },
"ConnectionStrings": {
"Postgres": "Host=localhost;Database=livraria;Username=livraria;Password=livraria",
"MySql": "Server=localhost;Database=livraria;User=livraria;Password=livraria",
"Mongo": "mongodb://localhost:27017/?replicaSet=rs0"
}
}
var builder = WebApplication.CreateBuilder(args);
var provedor = builder.Configuration["Livraria:Provedor"] ?? "postgres";
switch (provedor)
{
case "postgres":
builder.Services.AdicionarLivrariaPostgres(
builder.Configuration.GetConnectionString("Postgres")!);
break;
case "mysql":
builder.Services.AdicionarLivrariaMySql(
builder.Configuration.GetConnectionString("MySql")!);
break;
case "mongo":
builder.Services.AdicionarLivrariaMongo(
builder.Configuration.GetConnectionString("Mongo")!);
break;
default:
throw new InvalidOperationException($"Provedor desconhecido: {provedor}");
}
builder.Services.AddScoped<ServicoDeVendas>();
var app = builder.Build();
Em produção, a variável de ambiente Livraria__Provedor=mysql sobrescreve o arquivo. O duplo sublinhado é o separador de seção que funciona em qualquer sistema operacional, porque o : não é válido em nomes de variável em todo lugar. A connection string de produção nunca vai para o appsettings.json do repositório. Ela entra por variável de ambiente ou por um cofre de segredos, e o artigo "Configuração, logging e testes de integração" aprofunda o assunto.
Nenhuma linha dos projetos de dados mudou, e esse é o ganho de "Um Repositório, Três Bancos". O ASP.NET Core usa o mesmo Microsoft.Extensions.DependencyInjection. A cada requisição ele cria um escopo, expõe esse escopo em HttpContext.RequestServices e o descarta quando a resposta termina. O LivrariaContext registrado como scoped passa a viver exatamente uma requisição. De bônus, quando o ambiente é Development, o host liga sozinho ValidateScopes e ValidateOnBuild, a mesma rede de segurança que ligamos à mão no CLI.
Ambientes, portas e launchSettings
O ambiente vem da variável ASPNETCORE_ENVIRONMENT (ou DOTNET_ENVIRONMENT), e o padrão é Production. Esse padrão é deliberado: esquecer de configurar o ambiente deve resultar no modo mais seguro, que não mostra páginas de erro detalhadas.
O dotnet new web cria o arquivo Properties/launchSettings.json com perfis de execução. Cada perfil define portas e variáveis de ambiente, e é dali que vem o ASPNETCORE_ENVIRONMENT=Development quando se roda dotnet run. Esse arquivo serve apenas ao desenvolvimento local e não vai para a publicação. Em produção, as portas são definidas por ASPNETCORE_URLS ou ASPNETCORE_HTTP_PORTS. As imagens de contêiner oficiais do .NET 8 em diante escutam na porta 8080 por padrão, e não mais na 80, porque rodam com um usuário sem privilégios.
dotnet run --project src/Livraria.Api
curl http://localhost:5080/produtos/1 # a porta é a do seu launchSettings.json, que o template sorteia
Minimal APIs ou controllers?
O ASP.NET Core continua suportando controllers MVC ([ApiController], ControllerBase), e muito código em produção usa esse modelo. As Minimal APIs, introduzidas no .NET 6, viraram a recomendação da Microsoft para APIs novas. Os motivos:
- Têm menos cerimônia.
- São mais rápidas, porque o Request Delegate Generator pode gerar o código de binding em tempo de compilação.
- São compatíveis com Native AOT via
WebApplication.CreateSlimBuilder, o que dá o arranque rápido que "Source Generators" anunciou e que "Performance e distribuição" vai medir.
Os controllers ainda ganham em alguns recursos específicos, como as convenções de model binding mais ricas e os filters do MVC. Neste curso usaremos Minimal APIs. Os conceitos de pipeline, DI, binding e resultados são os mesmos nos dois modelos, e quem aprendeu um lê o outro sem dificuldade.
O caminho de uma requisição agora está completo: o Kestrel aceita a conexão, o pipeline passa o HttpContext de camada em camada, o roteamento escolhe um endpoint e o binding transforma rota, query, corpo e contêiner em argumentos de uma função comum. Falta dar a essa função um contrato decente.
Fontes e leituras recomendadas
- Microsoft Learn — Minimal APIs overview: https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/overview
- Microsoft Learn — ASP.NET Core Middleware (inclui a ordem recomendada do pipeline): https://learn.microsoft.com/aspnet/core/fundamentals/middleware/
- Microsoft Learn — Parameter binding in Minimal API apps: https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/parameter-binding
- Microsoft Learn — Routing in ASP.NET Core: https://learn.microsoft.com/aspnet/core/fundamentals/routing
- Andrew Lock — ASP.NET Core in Action, 3ª edição (Manning, 2023), capítulos sobre pipeline e Minimal APIs.
- RFC 9110 — HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110
Exercícios
Exercício 1
Registre três middlewares inline que escrevem no console "A-ida", "B-ida" e "C-ida" antes de chamar o próximo e "A-volta", "B-volta" e "C-volta" depois. Qual ordem aparece no console para uma requisição? E se o B não chamar o próximo?
Ver resposta
✓ Resposta: Com todos chamando o próximo, a saída é A-ida, B-ida, C-ida, depois o endpoint, depois C-volta, B-volta, A-volta: a ida segue a ordem de registro e a volta segue a ordem inversa, como numa pilha de chamadas. Se o B fizer short-circuit, a saída é A-ida, B-ida, B-volta (se ele imprimir antes de retornar) e A-volta. O C e o endpoint nunca rodam.
Exercício 2
Um colega escreveu um middleware que grava o cabeçalho X-Versao depois de await proximo(contexto). Em alguns endpoints funciona e em outros lança "Headers are read-only". Por que o comportamento varia, e como corrigir?
Ver resposta
✓ Resposta: Depende de o endpoint ter escrito algum corpo. A primeira escrita no corpo, por menor que seja, inicia a resposta e trava os cabeçalhos: medido no .NET 10, até um endpoint que devolve o texto "oi" (2 bytes) ou um JSON curto lança a exceção. Funciona só nos endpoints que não escrevem corpo nenhum, como um 204 No Content, um 404 de rota inexistente ou um redirecionamento. Daí a impressão de que "às vezes funciona". Há duas correções: escrever o cabeçalho antes de chamar o próximo, quando o valor já é conhecido, ou registrar contexto.Response.OnStarting(...), que roda no último momento em que ainda é possível alterar os cabeçalhos. Checar contexto.Response.HasStarted evita a exceção, mas não entrega o cabeçalho.
Exercício 3
Por que este middleware é um bug, e como reescrevê-lo?
public sealed class Auditoria(RequestDelegate proximo, LivrariaContext db)
{
public async Task InvokeAsync(HttpContext c) { /* grava em db */ await proximo(c); }
}
Ver resposta
✓ Resposta: Middlewares baseados em convenção são criados uma única vez. O LivrariaContext, que é scoped, fica capturado para sempre e é compartilhado entre requisições concorrentes. O DbContext não é thread-safe, e o change tracker acumula entidades sem parar. Em Development o host detecta a situação e falha na inicialização, porque ValidateScopes está ligado. A correção é mover a dependência para o método: public async Task InvokeAsync(HttpContext c, LivrariaContext db). Outra opção é implementar IMiddleware e registrar a classe como scoped.
Exercício 4
Com a rota app.MapGet("/produtos/{id:int}", ...), o que o cliente recebe em GET /produtos/abc? E em GET /produtos/99999999999? Por que usar restrições de rota como validação é má ideia?
Ver resposta
✓ Resposta: Em /produtos/abc a restrição int não casa, nenhuma rota é encontrada e a resposta é 404. Em /produtos/99999999999 o valor não cabe num int, a restrição também falha e a resposta é outro 404. O cliente recebe "não existe" quando o problema é "dado mal formado", sem mensagem nenhuma. Restrições servem para escolher entre rotas (por exemplo, /produtos/{id:int} e /produtos/{slug}). A validação deve produzir um 400 com uma explicação, como veremos em "Validação, ProblemDetails e OpenAPI".
Exercício 5
Faça a API aceitar a variável de ambiente LIVRARIA_PROVEDOR, a mesma do CLI, além de Livraria__Provedor, sem duplicar o switch. Qual das duas deve vencer se ambas estiverem definidas?
Ver resposta
✓ Resposta: Uma forma simples é var provedor = builder.Configuration["Livraria:Provedor"] ?? Environment.GetEnvironmentVariable("LIVRARIA_PROVEDOR") ?? "postgres";. Uma alternativa mais elegante é mapear a variável antiga para a nova chave com builder.Configuration.AddInMemoryCollection(...) antes de ler, ou usar AddEnvironmentVariables(prefix: "LIVRARIA_"), que expõe LIVRARIA_PROVEDOR como a chave PROVEDOR. Deve vencer a forma nova (Livraria__Provedor), porque é a convenção da configuração hierárquica e a variável antiga existe só por compatibilidade. Vale registrar um aviso no log quando a antiga for usada, para que ela possa ser removida depois.