Web APIs no ASP.NET Core 10: contratos HTTP
Na aula anterior, acompanhamos uma requisição por dentro do ASP.NET Core.
Vimos Kestrel, HttpContext, middleware, routing, endpoints, configuração, logging e tratamento global de erros.
Agora vamos subir mais um nível.
A pergunta deixa de ser apenas:
Como uma requisição chega ao meu código?
E passa a ser:
Como projetar uma API que outros sistemas consigam usar corretamente hoje e continuar usando amanhã?
Essa diferença é enorme.
Criar um endpoint que retorna JSON é fácil:
app.MapGet("/orders", () =>
{
return new[]
{
new
{
id = Guid.NewGuid(),
total = 125.50m
}
};
});
Mas uma Web API real precisa responder perguntas muito mais difíceis:
Qual URL representa um pedido?
GET pode alterar estado?
Quando usar POST, PUT, PATCH e DELETE?
Uma criação deve retornar 200 ou 201?
Como informar onde o recurso criado está?
O que acontece se o cliente repetir a mesma requisição?
Como representar erros de forma consistente?
DTO e entidade de domínio deveriam ser a mesma classe?
Como validar entrada sem misturar regra de negócio com regra de transporte?
Como paginar milhões de registros?
Como evoluir um contrato sem quebrar aplicativos antigos?
O que acontece quando o usuário fecha a conexão no meio de uma operação cara?
Como um endpoint de IA deve reagir quando o cliente abandona a requisição?
Essas perguntas fazem parte do design de uma Web API.
O framework ajuda muito, mas ele não escolhe a semântica por nós.
O ASP.NET Core 10 suporta APIs baseadas em Controllers e Minimal APIs. Nesta aula vamos usar principalmente Controllers para manter o foco nos contratos HTTP. Na próxima aula, vamos estudar Minimal APIs em profundidade e implementar os mesmos princípios com uma organização diferente.
O objetivo aqui não é aprender apenas atributos como [HttpGet] e [HttpPost].
É aprender a tratar HTTP como um protocolo com semântica.
Essa habilidade será reutilizada em praticamente todo o roadmap, inclusive quando chegarmos a aplicações de IA, RAG, agentes, upload de documentos, streaming e integrações entre serviços.
Uma API é um contrato, não uma coleção de métodos públicos
Considere uma classe:
public sealed class OrderService
{
public Order GetOrder(Guid id)
{
throw new NotImplementedException();
}
public void CancelOrder(Guid id)
{
throw new NotImplementedException();
}
}
Dentro do mesmo processo, chamar esses métodos é relativamente direto.
Uma Web API cria uma fronteira diferente.
Agora existem:
cliente
|
| rede
v
servidor
Entre os dois lados temos um protocolo.
O cliente não chama diretamente:
orderService.GetOrder(id);
Ele envia algo como:
GET /api/orders/8ff54ce9-807c-41a5-8ee6-f796972e0fb6 HTTP/1.1
Host: api.example.com
Accept: application/json
E recebe:
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "8ff54ce9-807c-41a5-8ee6-f796972e0fb6",
"customerName": "Maria",
"total": 125.50,
"status": "pending"
}
Esse contrato possui várias dimensões:
método HTTP
URL
headers
query string
request body
status code
response headers
response body
media type
semântica
Uma mudança em qualquer uma dessas dimensões pode afetar consumidores.
Por isso, o primeiro princípio desta aula é:
Uma API publicada é uma promessa para seus consumidores.
Mesmo quando você controla frontend e backend, existe um contrato entre versões implantadas em momentos diferentes.
Um aplicativo mobile antigo pode continuar instalado por meses.
Um parceiro externo pode atualizar a integração apenas uma vez por trimestre.
Um serviço interno pode fazer deploy em uma janela diferente.
A API precisa evoluir levando isso em consideração.
HTTP não é apenas um túnel para chamar métodos C#
Um anti-pattern comum é criar URLs que imitam funções:
POST /GetOrder
POST /CreateOrder
POST /UpdateOrder
POST /DeleteOrder
POST /CancelOrder
Tecnicamente, pode funcionar.
Mas perdemos parte importante da semântica do HTTP.
Uma abordagem orientada a recursos começa pensando em substantivos:
/orders
/orders/{id}
/customers/{id}
/products/{id}
Depois usamos métodos HTTP para expressar a intenção.
Por exemplo:
GET /orders/{id}
POST /orders
PUT /orders/{id}
DELETE /orders/{id}
Isso não significa que toda operação de negócio precisa ser forçada artificialmente a um CRUD.
Uma operação como cancelamento pode representar uma transição explícita de negócio.
Podemos modelar:
POST /orders/{id}/cancellations
ou, dependendo do contrato e da semântica desejada:
POST /orders/{id}/cancel
O primeiro formato modela o cancelamento como um recurso.
O segundo modela uma ação.
Ambos podem existir em APIs reais.
O ponto não é seguir uma religião de URLs.
O ponto é manter uma semântica consistente e previsível.
GET precisa ser seguro
O RFC 9110 define métodos seguros como aqueles cuja semântica é essencialmente de leitura. Entre os métodos definidos na especificação, GET, HEAD, OPTIONS e TRACE são considerados seguros.
Isso não significa que um GET não possa gerar nenhum efeito colateral técnico.
Ao executar:
GET /orders/123
o servidor pode:
registrar log
incrementar métrica
atualizar telemetria
preencher cache
Esses efeitos não representam a intenção de negócio solicitada pelo cliente.
O que não deveríamos fazer é isto:
GET /orders/123/cancel
e cancelar o pedido.
Por quê?
Porque ferramentas podem executar GET automaticamente:
crawlers
prefetchers
proxies
navegadores
monitores
caches
Um link que altera estado por GET pode produzir efeitos inesperados.
Regra prática:
GET
consulta estado
não deveria representar uma solicitação de mudança de estado
Idempotência é diferente de segurança
Esses dois conceitos são frequentemente confundidos.
Um método seguro tem semântica essencialmente de leitura.
Um método idempotente tem uma propriedade diferente:
repetir a mesma requisição deve produzir o mesmo efeito pretendido no servidor que executá-la uma única vez.
O RFC 9110 classifica PUT, DELETE e os métodos seguros como idempotentes por definição de sua semântica.
Considere:
DELETE /orders/123
Primeira chamada:
pedido removido
Segunda chamada:
pedido já não existe
As respostas podem ser diferentes.
Por exemplo:
primeira resposta: 204
segunda resposta: 404
Mesmo assim, o efeito desejado após uma ou dez chamadas continua sendo:
o recurso não existe
Agora compare com:
POST /payments
Body:
{
"orderId": "123",
"amount": 50.00
}
Se o cliente não recebe a resposta por uma falha de rede e repete o POST, podemos acabar cobrando duas vezes.
Esse é um problema real de sistemas distribuídos.
Mais adiante, quando estudarmos arquitetura distribuída, vamos aprofundar idempotency keys, deduplicação e processamento idempotente.
Nesta aula, guarde a diferença:
safe
intenção de leitura
idempotent
repetir a operação não muda o efeito pretendido final
Criando nosso projeto de laboratório
Vamos continuar com o domínio de pedidos.
Crie:
dotnet new webapi \n -n Logby.Store.Api \n --framework net10.0
cd Logby.Store.Api
Vamos organizar inicialmente:
Logby.Store.Api
|
+-- Controllers
| +-- OrdersController.cs
|
+-- Contracts
| +-- CreateOrderRequest.cs
| +-- OrderResponse.cs
| +-- PagedResponse.cs
|
+-- Domain
| +-- Order.cs
| +-- OrderStatus.cs
|
+-- Services
| +-- OrderService.cs
|
+-- Program.cs
Ainda não estamos aplicando Clean Architecture.
Isso virá mais adiante no roadmap.
Neste momento queremos apenas separar conceitos suficientes para enxergar o contrato HTTP.
No Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddSingleton<OrderService>();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapControllers();
app.Run();
Temos três pontos centrais.
builder.Services.AddControllers();
registra a infraestrutura necessária para Controllers.
app.MapControllers();
adiciona os endpoints descobertos através do modelo de controllers e attribute routing.
builder.Services.AddProblemDetails();
registra a infraestrutura padrão para Problem Details.
Agora vamos construir o contrato.
ControllerBase e ApiController
Crie:
using Microsoft.AspNetCore.Mvc;
namespace Logby.Store.Api.Controllers;
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
}
Para Web APIs baseadas em controllers, ControllerBase é normalmente a base adequada.
Controller, por outro lado, adiciona funcionalidades relacionadas a views, usadas em aplicações MVC que renderizam HTML.
O atributo:
[ApiController]
habilita comportamentos opinativos para APIs, incluindo inferência de fontes de binding, respostas automáticas de validação em vários cenários e integração de respostas de erro com o modelo de API.
O atributo:
[Route("api/orders")]
define a base do contrato de URL do controller.
Agora podemos adicionar operações.
GET de coleção e GET de recurso são contratos diferentes
Vamos começar com:
[HttpGet]
public ActionResult<IReadOnlyCollection<OrderResponse>> GetAll()
{
throw new NotImplementedException();
}
Esse endpoint corresponde a:
GET /api/orders
Agora outro:
[HttpGet("{id:guid}")]
public ActionResult<OrderResponse> GetById(Guid id)
{
throw new NotImplementedException();
}
Contrato:
GET /api/orders/{id}
Embora ambos consultem pedidos, eles representam recursos diferentes.
O primeiro representa uma coleção.
O segundo representa um membro específico.
Essa diferença afeta:
paginação
cache
status codes
query parameters
forma da resposta
documentação
Não trate automaticamente:
GET /orders
como um método que sempre retorna todos os registros do banco.
Em produção, "todos" pode significar milhões.
Voltaremos à paginação.
DTO não é entidade de domínio
Crie a entidade:
namespace Logby.Store.Api.Domain;
public sealed class Order
{
public Guid Id { get; private set; }
public string CustomerName { get; private set; }
public decimal Total { get; private set; }
public OrderStatus Status { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public Order(
Guid id,
string customerName,
decimal total,
DateTimeOffset createdAt)
{
if (id == Guid.Empty)
{
throw new ArgumentException(
"Order id is required.",
nameof(id));
}
if (string.IsNullOrWhiteSpace(customerName))
{
throw new ArgumentException(
"Customer name is required.",
nameof(customerName));
}
if (total < 0)
{
throw new ArgumentOutOfRangeException(
nameof(total));
}
Id = id;
CustomerName = customerName.Trim();
Total = total;
Status = OrderStatus.Pending;
CreatedAt = createdAt;
}
}
E:
namespace Logby.Store.Api.Domain;
public enum OrderStatus
{
Pending,
Paid,
Cancelled
}
Agora imagine retornar Order diretamente.
return Ok(order);
No começo parece conveniente.
Mas o contrato externo fica acoplado à estrutura interna da entidade.
Se amanhã adicionarmos:
public string InternalFraudAnalysis { get; private set; }
podemos expor acidentalmente um campo interno.
Ou podemos renomear uma propriedade por motivo de domínio e quebrar consumidores externos.
Por isso, criamos um DTO de resposta.
namespace Logby.Store.Api.Contracts;
public sealed record OrderResponse(
Guid Id,
string CustomerName,
decimal Total,
string Status,
DateTimeOffset CreatedAt);
Agora precisamos mapear explicitamente.
private static OrderResponse Map(Order order)
{
return new OrderResponse(
order.Id,
order.CustomerName,
order.Total,
order.Status.ToString().ToLowerInvariant(),
order.CreatedAt);
}
Essa duplicação não é necessariamente desperdício.
Ela cria uma fronteira.
modelo interno
|
| mapping
v
contrato externo
Isso nos permite evoluir os dois lados com ritmos diferentes.
Quando entidade e DTO iguais são aceitáveis?
Em um protótipo pequeno, ferramenta interna descartável ou domínio extremamente simples, usar o mesmo tipo pode ser pragmático.
Mas quanto maior o tempo de vida da API, maior o valor de separar contratos públicos de modelos internos.
Especialmente em:
APIs públicas
integrações entre equipes
mobile backends
microservices
sistemas financeiros
sistemas regulados
plataformas de IA
Contratos públicos merecem estabilidade deliberada.
Implementando um serviço simples
Crie:
using Logby.Store.Api.Domain;
namespace Logby.Store.Api.Services;
public sealed class OrderService
{
private readonly Dictionary<Guid, Order> _orders = [];
public OrderService()
{
AddSeed(
"Maria",
125.50m);
AddSeed(
"João",
899.90m);
AddSeed(
"Ana",
42.00m);
}
public IReadOnlyCollection<Order> GetAll()
{
return _orders.Values.ToArray();
}
public Order? GetById(Guid id)
{
return _orders.GetValueOrDefault(id);
}
public Order Create(
string customerName,
decimal total)
{
var order = new Order(
Guid.NewGuid(),
customerName,
total,
DateTimeOffset.UtcNow);
_orders.Add(
order.Id,
order);
return order;
}
private void AddSeed(
string customerName,
decimal total)
{
var order = new Order(
Guid.NewGuid(),
customerName,
total,
DateTimeOffset.UtcNow);
_orders.Add(
order.Id,
order);
}
}
Este armazenamento em memória existe apenas para a aula.
Como registramos OrderService como singleton e usamos um Dictionary, não deveríamos tratar essa implementação como armazenamento concorrente adequado para produção.
Na aula de Dependency Injection vamos aprofundar lifetimes.
Quando chegarmos a Entity Framework Core, substituiremos armazenamento em memória por persistência real.
Retornando 200 corretamente
Implemente o controller:
using Logby.Store.Api.Contracts;
using Logby.Store.Api.Domain;
using Logby.Store.Api.Services;
using Microsoft.AspNetCore.Mvc;
namespace Logby.Store.Api.Controllers;
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
private readonly OrderService _orderService;
public OrdersController(
OrderService orderService)
{
_orderService = orderService;
}
[HttpGet]
[ProducesResponseType<IReadOnlyCollection<OrderResponse>>(
StatusCodes.Status200OK)]
public ActionResult<IReadOnlyCollection<OrderResponse>> GetAll()
{
var orders = _orderService
.GetAll()
.Select(Map)
.ToArray();
return Ok(orders);
}
private static OrderResponse Map(Order order)
{
return new OrderResponse(
order.Id,
order.CustomerName,
order.Total,
order.Status.ToString().ToLowerInvariant(),
order.CreatedAt);
}
}
200 OK comunica sucesso.
Mas o status code não deve ser escolhido apenas porque "200 é sucesso".
HTTP possui códigos com significados diferentes.
Nosso trabalho é escolher o que melhor representa o resultado.
Para uma consulta bem-sucedida de coleção:
GET /api/orders
200 OK com uma representação da coleção é natural.
Inclusive quando não existem itens:
[]
Um erro comum é retornar 404 para uma coleção vazia.
Compare:
GET /orders
O recurso "coleção de pedidos" existe.
Ele apenas possui zero elementos.
Uma resposta natural:
200 OK
[]
Agora:
GET /orders/{id}
é diferente.
Estamos pedindo um recurso específico.
Se ele não existe:
404 Not Found
faz sentido.
Implementando 404 para recurso inexistente
Adicione:
[HttpGet("{id:guid}")]
[ProducesResponseType<OrderResponse>(
StatusCodes.Status200OK)]
[ProducesResponseType(
StatusCodes.Status404NotFound)]
public ActionResult<OrderResponse> GetById(Guid id)
{
var order = _orderService.GetById(id);
if (order is null)
{
return NotFound();
}
return Ok(Map(order));
}
Agora temos duas possibilidades explícitas.
pedido encontrado
200
pedido inexistente
404
ActionResult<T> é útil aqui porque a action pode retornar tanto um valor tipado quanto resultados HTTP diferentes.
O ASP.NET Core fornece vários tipos e métodos auxiliares para representar status codes, como:
Ok(...)
Created(...)
NotFound(...)
BadRequest(...)
NoContent()
Conflict(...)
Isso melhora a legibilidade do contrato.
400 não significa "qualquer coisa deu errado"
Esse é um erro extremamente comum.
400 Bad Request
não deveria virar:
erro genérico de qualquer natureza
Imagine:
banco indisponível
Isso não significa que o cliente enviou uma requisição inválida.
Retornar 400 esconderia a natureza real da falha.
Uma taxonomia simples:
400
requisição sintaticamente ou estruturalmente inválida
401
cliente não autenticado adequadamente
403
identidade conhecida, mas acesso não permitido
404
recurso não encontrado
409
conflito com o estado atual do recurso
422
conteúdo semanticamente inválido em determinados contratos
429
limite de requisições excedido
500
falha inesperada no servidor
503
serviço temporariamente indisponível
Não transforme essa tabela em uma regra mecânica.
Leia a semântica de cada cenário.
O importante é não retornar 200 com:
{
"success": false,
"error": "Order not found"
}
para tudo.
Status codes existem justamente para transportar semântica de protocolo.
POST para criação de recurso
Crie o contrato:
using System.ComponentModel.DataAnnotations;
namespace Logby.Store.Api.Contracts;
public sealed record CreateOrderRequest(
[property: Required]
[property: StringLength(120, MinimumLength = 2)]
string CustomerName,
[property: Range(
typeof(decimal),
"0.01",
"999999999")]
decimal Total);
Agora adicione:
[HttpPost]
[ProducesResponseType<OrderResponse>(
StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(
StatusCodes.Status400BadRequest)]
public ActionResult<OrderResponse> Create(
CreateOrderRequest request)
{
var order = _orderService.Create(
request.CustomerName,
request.Total);
return CreatedAtAction(
nameof(GetById),
new
{
id = order.Id
},
Map(order));
}
Quando a criação é bem-sucedida, retornamos:
201 Created
Location: /api/orders/{novo-id}
com a representação criada.
CreatedAtAction ajuda a produzir esse contrato.
Esse detalhe importa porque o cliente recebe duas informações:
o recurso foi criado
este é o endereço onde ele pode ser obtido
Retornar apenas:
200 OK
não seria necessariamente inválido em qualquer operação POST, mas perderíamos a semântica específica de criação quando estamos realmente criando um novo recurso identificável.
Model binding: como o HTTP vira tipos C#
Observe este método:
public ActionResult<OrderResponse> Create(
CreateOrderRequest request)
O cliente envia JSON:
{
"customerName": "Maria",
"total": 199.90
}
O ASP.NET Core transforma dados da requisição em um objeto C#.
Esse processo faz parte de model binding e deserialização do body.
Dependendo do estilo de endpoint, parâmetros podem vir de:
route
query string
headers
body
form
dependency injection
Em controllers, podemos tornar a origem explícita.
public IActionResult Search(
[FromQuery] string status,
[FromHeader(Name = "X-Tenant-Id")] string tenantId)
E:
public IActionResult GetById(
[FromRoute] Guid id)
Com [ApiController], várias fontes podem ser inferidas automaticamente.
Mesmo assim, atributos explícitos podem melhorar clareza quando existe ambiguidade ou quando queremos deixar o contrato evidente.
Não receba HttpRequest inteiro sem necessidade
É possível fazer:
var value =
Request.Query["status"];
Mas se o valor faz parte do contrato do endpoint, é frequentemente melhor declarar:
[FromQuery] string? status
Agora:
o compilador conhece o tipo
a documentação consegue descrever melhor
testes ficam mais simples
a assinatura comunica o contrato
O framework existe para evitar parsing manual repetitivo.
Binding não é validação de negócio
Essa distinção é fundamental.
Considere:
{
"customerName": "",
"total": -10
}
O JSON pode ser perfeitamente válido.
A desserialização pode funcionar.
Mas os valores violam regras do contrato.
Isso é validação.
Agora considere:
{
"customerName": "Maria",
"total": "banana"
}
O valor não consegue ser convertido corretamente para decimal.
Isso é um problema de binding ou deserialização.
Ambos podem resultar em erro de cliente, mas são problemas diferentes.
E ainda existe uma terceira categoria:
cliente existe?
estoque está disponível?
pedido já foi pago?
cupom expirou?
Essas são regras de negócio.
Não coloque tudo em DataAnnotations.
Validação de entrada com ApiController
Nosso DTO usa:
[Required]
[StringLength(...)]
[Range(...)]
Com [ApiController], erros de model validation normalmente produzem resposta HTTP 400 automaticamente, sem a necessidade de repetir:
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
Isso reduz código cerimonial.
Uma resposta de validação pode ser representada como ValidationProblemDetails, contendo informações estruturadas sobre os campos inválidos.
Exemplo conceitual:
{
"type": "https://example.com/problems/validation",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"CustomerName": [
"The CustomerName field is required."
]
}
}
O formato exato pode variar conforme customização e versão.
A ideia importante é:
erro também é contrato.
Não retorne um formato diferente em cada endpoint.
Evite:
endpoint A
{ "error": "invalid" }
endpoint B
{ "message": "wrong input" }
endpoint C
{ "success": false, "errors": [...] }
Consistência vale muito mais do que inventar um envelope diferente para cada caso.
DataAnnotations não deveria carregar todas as regras do domínio
Considere:
um pedido só pode ser cancelado enquanto não estiver pago
Seria estranho tentar expressar toda essa regra em:
[SomeMagicValidation]
no DTO.
A entrada pode ser estruturalmente válida.
O problema está no estado atual do negócio.
Uma separação útil:
validação de transporte
|
+-- campo obrigatório
+-- tamanho
+-- formato
+-- faixa básica
regra de negócio
|
+-- transições permitidas
+-- políticas comerciais
+-- estoque
+-- permissões de domínio
+-- consistência entre agregados
Essa divisão evita transformar controllers e DTOs em um domínio disfarçado.
Problem Details: erros também precisam de um padrão
O RFC 9457 define Problem Details para transportar detalhes legíveis por máquina sobre erros em respostas HTTP.
Em ASP.NET Core podemos registrar:
builder.Services.AddProblemDetails();
e configurar:
app.UseExceptionHandler();
app.UseStatusCodePages();
Para um conflito de negócio, poderíamos produzir:
return Problem(
statusCode: StatusCodes.Status409Conflict,
title: "Order cannot be cancelled",
detail: "A paid order cannot be cancelled.",
type: "https://api.example.com/problems/order-cannot-be-cancelled");
Resposta:
{
"type": "https://api.example.com/problems/order-cannot-be-cancelled",
"title": "Order cannot be cancelled",
"status": 409,
"detail": "A paid order cannot be cancelled."
}
Agora consumidores podem reagir de forma previsível.
O campo type é particularmente útil quando representa um identificador estável do tipo de problema.
Não dependa apenas da mensagem humana:
"A paid order cannot be cancelled."
Mensagens mudam.
Podem ser traduzidas.
Podem receber ajustes de texto.
Um identificador estável facilita integração.
Não vaze exceções internas no contrato
Evite isto:
{
"error": "System.Data.SqlClient.SqlException",
"stackTrace": "...",
"connectionString": "..."
}
Uma exceção interna não é um contrato público.
Ela pode conter:
stack trace
nomes de tabelas
paths
infraestrutura
segredos
detalhes de bibliotecas
O cliente precisa saber:
o que aconteceu do ponto de vista da API
se pode corrigir a requisição
se pode tentar novamente
qual identificador usar para suporte
O log interno pode conter detalhes técnicos apropriados.
A resposta pública deve ser controlada.
PUT e PATCH não são sinônimos
Considere atualização de pedido.
Uma abordagem:
PUT /api/orders/{id}
Body:
{
"customerName": "Maria",
"total": 299.90
}
A semântica de PUT é normalmente entendida como criação ou substituição do estado do recurso de destino pela representação enviada, de acordo com o contrato daquele recurso.
Isso tem uma consequência importante.
Se nosso DTO possui:
{
"customerName": "Maria"
}
e omitimos total, precisamos ter uma definição clara do que a ausência significa.
Em uma substituição completa, omissão não deveria ser confundida automaticamente com:
"não altere esse campo"
Essa semântica é mais próxima de atualização parcial.
Para atualizações parciais, PATCH é uma opção, mas também exige definir um formato de patch e regras claras.
Não escolha entre PUT e PATCH pela quantidade de linhas no controller.
Escolha pela semântica do contrato.
Uma alternativa orientada ao domínio
Muitas APIs não precisam expor um "edite qualquer propriedade".
Operações de negócio explícitas podem ser melhores.
Por exemplo:
POST /orders/{id}/cancellations
POST /orders/{id}/payments
POST /orders/{id}/confirmations
Isso reduz estados inválidos e comunica intenção.
Compare:
PATCH /orders/123
{
"status": "paid"
}
com:
POST /orders/123/payments
{
"paymentMethodId": "..."
}
A segunda forma pode representar melhor o evento de negócio.
Não existe regra universal.
A questão é:
estamos modelando dados ou uma operação de domínio com significado próprio?
DELETE e o status 204
Podemos implementar:
[HttpDelete("{id:guid}")]
[ProducesResponseType(
StatusCodes.Status204NoContent)]
[ProducesResponseType(
StatusCodes.Status404NotFound)]
public IActionResult Delete(Guid id)
{
var removed =
_orderService.Delete(id);
if (!removed)
{
return NotFound();
}
return NoContent();
}
204 No Content comunica sucesso sem corpo de resposta.
O método no serviço:
public bool Delete(Guid id)
{
return _orders.Remove(id);
}
Novamente, nosso armazenamento é didático.
Em um sistema real, "deletar pedido" talvez nem seja permitido.
Podemos precisar de:
cancelamento lógico
auditoria
retenção legal
soft delete
eventos de domínio
Não deixe o verbo HTTP definir sozinho a regra do negócio.
HTTP é o contrato de transporte.
O domínio continua tendo suas próprias restrições.
409 Conflict para conflito de estado
Imagine:
pedido já foi pago
cliente tenta cancelar
O request pode estar perfeitamente formado.
O recurso existe.
O usuário tem permissão.
Mas a operação conflita com o estado atual.
Podemos representar:
409 Conflict
Exemplo:
[HttpPost("{id:guid}/cancellations")]
[ProducesResponseType(
StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(
StatusCodes.Status409Conflict)]
[ProducesResponseType(
StatusCodes.Status404NotFound)]
public IActionResult Cancel(Guid id)
{
var order =
_orderService.GetById(id);
if (order is null)
{
return NotFound();
}
if (order.Status == OrderStatus.Paid)
{
return Problem(
statusCode:
StatusCodes.Status409Conflict,
title:
"Order cannot be cancelled",
detail:
"Paid orders cannot be cancelled.",
type:
"https://api.example.com/problems/order-cannot-be-cancelled");
}
order.Cancel();
return NoContent();
}
O relevante aqui é distinguir:
request inválido
vs
estado incompatível
Essa diferença ajuda clientes a decidir o que fazer.
Idempotência em POST exige projeto explícito
Vamos imaginar criação de pagamento:
POST /api/payments
{
"orderId": "...",
"amount": 100.00
}
Fluxo:
cliente envia POST
|
v
servidor cobra cartão
|
v
servidor envia resposta
|
X
conexão cai
O cliente não sabe se a cobrança aconteceu.
Ele tenta novamente.
Sem uma estratégia, podemos cobrar duas vezes.
Uma solução comum é uma chave de idempotência controlada pelo cliente.
POST /api/payments
Idempotency-Key: 2dc81da4-7d0a-48e6-a5a0-d5613d430f33
O servidor mantém um registro:
chave
|
+-- request associado
+-- resultado
Ao receber novamente a mesma operação:
mesma chave
|
v
não executa o efeito novamente
|
v
retorna resultado compatível
A implementação correta exige decisões sobre:
tempo de retenção
armazenamento
concorrência
transação
hash do payload
reuso incorreto da chave
status em processamento
falhas parciais
Por isso, não vamos implementar uma versão falsa em memória e chamar de pronta.
Voltaremos a esse assunto em sistemas distribuídos.
A lição aqui é:
retry sem idempotência pode transformar uma falha temporária em duplicação de efeito.
Isso será muito importante quando nossos endpoints acionarem:
pagamentos
mensageria
jobs
tools de agentes
operações de IA com custo
Paginação não é otimização opcional
Este endpoint parece inocente:
[HttpGet]
public ActionResult<IReadOnlyCollection<OrderResponse>> GetAll()
{
return Ok(
_orderService
.GetAll()
.Select(Map)
.ToArray());
}
Agora imagine:
10 pedidos
100 pedidos
100 mil pedidos
50 milhões de pedidos
O contrato continua dizendo:
me dê tudo
Em sistemas reais, isso não escala.
Uma primeira paginação por página:
GET /api/orders?page=2&pageSize=20
Crie:
namespace Logby.Store.Api.Contracts;
public sealed record PagedResponse<T>(
IReadOnlyCollection<T> Items,
int Page,
int PageSize,
int TotalItems,
int TotalPages);
Controller:
[HttpGet]
public ActionResult<PagedResponse<OrderResponse>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
if (page < 1)
{
ModelState.AddModelError(
nameof(page),
"Page must be greater than zero.");
}
if (pageSize is < 1 or > 100)
{
ModelState.AddModelError(
nameof(pageSize),
"Page size must be between 1 and 100.");
}
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
var all =
_orderService
.GetAll()
.OrderByDescending(
order => order.CreatedAt)
.ThenBy(
order => order.Id)
.ToArray();
var items =
all
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(Map)
.ToArray();
var totalPages =
(int)Math.Ceiling(
all.Length /
(double)pageSize);
return Ok(
new PagedResponse<OrderResponse>(
items,
page,
pageSize,
all.Length,
totalPages));
}
Para nosso laboratório em memória isso funciona.
Em banco de dados, a paginação precisa acontecer na consulta, não depois de carregar tudo.
Ou seja, isto será ruim com milhões de registros:
SELECT tudo
carrega na memória
Skip
Take
Quando chegarmos a EF Core, vamos gerar paginação no banco.
Paginação precisa de ordenação estável
Considere uma lista sem ordenação explícita.
Página 1:
A
B
C
Entre duas requisições, um novo item entra.
Página 2 pode:
repetir C
pular D
mudar ordem
Por isso, paginação precisa de uma ordenação previsível.
No exemplo usamos:
.OrderByDescending(
order => order.CreatedAt)
.ThenBy(
order => order.Id)
O segundo critério ajuda a estabilizar empates.
Em grandes volumes ou feeds muito dinâmicos, paginação baseada em cursor pode ser melhor que offset.
Exemplo:
GET /api/orders?limit=20&after=eyJ...
O cursor pode representar a posição da ordenação.
Trade-offs:
offset pagination
+ simples para navegar por número de página
+ fácil de entender
- páginas profundas podem ficar caras
- alterações concorrentes podem causar inconsistência visual
cursor pagination
+ eficiente para feeds sequenciais
+ melhor estabilidade em grandes conjuntos
- não oferece salto natural para "página 73"
- contrato é um pouco mais complexo
Escolha de acordo com a experiência do consumidor e características dos dados.
Filtros e ordenação também fazem parte do contrato
Podemos oferecer:
GET /api/orders?status=paid&sort=-createdAt
Evite permitir que nomes arbitrários do banco virem API pública.
Isto é perigoso conceitualmente:
?sort=InternalDatabaseColumn
porque acopla consumidor ao schema interno.
Defina um conjunto explícito.
Por exemplo:
sort=createdAt
sort=-createdAt
sort=total
sort=-total
A API controla como cada valor é traduzido internamente.
Isso preserva a fronteira entre contrato e persistência.
Nunca confie no cliente para definir limites ilimitados
Este endpoint:
GET /orders?pageSize=10000000
não deveria obrigar o servidor a cumprir cegamente o valor.
Defina limites:
default: 20
max: 100
O objetivo é proteger:
memória
CPU
banco
tempo de resposta
largura de banda
custo
Uma API é uma fronteira de recursos.
Validação de limites também é parte de resiliência.
Status codes não devem depender do gosto do time
Algumas decisões comuns:
GET de recurso encontrado
200 OK
GET de recurso inexistente
404 Not Found
POST que criou novo recurso identificável
201 Created
Location: /resource/{id}
Operação concluída sem conteúdo de resposta
204 No Content
Entrada inválida
400 Bad Request
Falha de autenticação
401 Unauthorized
Apesar do nome histórico, 401 significa que a requisição não possui credenciais de autenticação válidas para obter a resposta.
Sem autorização suficiente
403 Forbidden
Conflito com estado atual
409 Conflict
Rate limit
429 Too Many Requests
Falha inesperada
500 Internal Server Error
O principal é documentar e manter consistência.
Não crie uma convenção interna como:
sempre retornamos 200
e colocamos um code no JSON
Isso reduz o valor semântico do próprio protocolo.
202 Accepted para trabalho assíncrono
Imagine upload de um documento que exige:
upload
extração de texto
chunking
embeddings
indexação vetorial
Talvez leve minutos.
Não precisamos manter uma requisição HTTP aberta durante todo o processo.
Podemos criar:
POST /api/documents
Resposta:
202 Accepted
Location: /api/document-jobs/123
Body:
{
"jobId": "123",
"status": "queued"
}
Depois:
GET /api/document-jobs/123
Resposta:
{
"jobId": "123",
"status": "processing"
}
Esse padrão será extremamente importante quando chegarmos a RAG e Background Services.
202 Accepted comunica que a requisição foi aceita para processamento, não que o trabalho foi concluído.
Não retorne:
200 OK
com "status": "queued" apenas por hábito, se a semântica assíncrona faz parte do contrato.
CancellationToken é parte da economia da API
Considere um endpoint futuro:
[HttpGet("{id:guid}")]
public async Task<ActionResult<OrderResponse>> GetById(
Guid id,
CancellationToken cancellationToken)
{
var order =
await _repository.GetByIdAsync(
id,
cancellationToken);
if (order is null)
{
return NotFound();
}
return Ok(Map(order));
}
O CancellationToken associado à requisição pode ser propagado para operações assíncronas.
Isso importa quando:
cliente fecha navegador
cliente cancela request
proxy encerra conexão
timeout da requisição dispara
Se continuarmos executando trabalho caro sem necessidade, desperdiçamos recursos.
Imagine um endpoint de IA:
cliente envia pergunta
|
v
busca vetorial
|
v
LLM gera resposta
|
X
cliente fechou conexão
Sem propagação de cancelamento:
modelo continua consumindo tokens
banco continua trabalhando
HTTP externo continua aberto
Isso pode virar custo real.
Portanto, uma regra que usaremos durante toda a série:
recebeu CancellationToken na fronteira
|
v
propague para operações que suportam cancelamento
Exemplo:
await dbContext.Orders
.SingleOrDefaultAsync(
x => x.Id == id,
cancellationToken);
E:
await httpClient.SendAsync(
request,
cancellationToken);
Não crie um token e depois ignore.
Cancelamento não é rollback automático
Outra distinção importante.
Se o token for cancelado depois de uma operação irreversível:
pagamento já foi capturado
mensagem já foi publicada
arquivo já foi persistido
cancelar o restante não desfaz automaticamente o que aconteceu.
CancellationToken sinaliza interrupção cooperativa.
Ele não fornece transação distribuída.
Quando chegarmos a sistemas distribuídos, vamos estudar compensações, outbox e idempotência.
Timeout também é contrato operacional
Uma requisição não deveria poder executar indefinidamente apenas porque algum serviço externo parou de responder.
ASP.NET Core possui middleware de request timeouts, e serviços externos também precisam de limites adequados.
Mas timeout não deve ser escolhido aleatoriamente.
Considere:
endpoint de health
esperar 5 minutos
não faz sentido
upload e processamento síncrono de documento gigante
timeout curto
pode tornar a operação inviável
Às vezes, a solução não é "aumentar o timeout".
É mudar o contrato.
De:
POST
espera 10 minutos
retorna resultado
Para:
POST
retorna 202
worker processa
cliente consulta status
Arquitetura e HTTP estão conectados.
Contratos de request e response devem ser diferentes quando necessário
Considere:
public sealed record OrderDto(
Guid Id,
string CustomerName,
decimal Total,
string Status,
DateTimeOffset CreatedAt);
Usar o mesmo DTO para criar:
POST /orders
obrigaria o cliente a enviar:
Id
Status
CreatedAt
Mas quem deveria controlar esses campos?
Provavelmente o servidor.
Por isso:
public sealed record CreateOrderRequest(
string CustomerName,
decimal Total);
e:
public sealed record OrderResponse(
Guid Id,
string CustomerName,
decimal Total,
string Status,
DateTimeOffset CreatedAt);
Contratos diferentes refletem responsabilidades diferentes.
Regra prática:
não crie um MegaDto universal apenas para evitar mapear propriedades.
A pequena duplicação pode preservar segurança e clareza.
Over-posting: quando o cliente consegue alterar o que não deveria
Imagine uma entidade:
public sealed class User
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public bool IsAdmin { get; set; }
}
Agora um endpoint recebe diretamente:
public IActionResult Update(User user)
Um cliente malicioso envia:
{
"id": "...",
"name": "Maria",
"isAdmin": true
}
Se o mapping for ingênuo, acabamos expondo uma propriedade que nunca deveria ser controlada pelo consumidor.
Um request específico:
public sealed record UpdateUserRequest(
string Name);
elimina essa superfície.
DTOs não são apenas organização.
Eles também ajudam a limitar o que entra e sai da aplicação.
Não coloque success em toda resposta por padrão
É comum encontrar:
{
"success": true,
"data": {
"id": "..."
},
"error": null
}
E em erro:
{
"success": false,
"data": null,
"error": "Not found"
}
Esse envelope pode ter utilidade em contextos específicos.
Mas não deveria existir apenas porque "toda API precisa de um wrapper".
HTTP já possui:
status code
headers
body
Para uma consulta simples:
200 OK
{
"id": "...",
"customerName": "Maria"
}
é suficiente.
Para erro:
404 Not Found
Content-Type: application/problem+json
com Problem Details.
Adicionar um envelope universal cria custo:
mais bytes
mais nesting
mais parsing
mais generics
mais documentação
mais acoplamento
Use quando existe um requisito concreto, não como ritual.
Versionamento não deve ser a primeira resposta para toda mudança
Imagine a versão 1:
{
"id": "...",
"customerName": "Maria"
}
Queremos adicionar:
{
"id": "...",
"customerName": "Maria",
"status": "pending"
}
Precisamos imediatamente criar /v2?
Nem sempre.
Adicionar um campo pode ser uma mudança compatível para consumidores que ignoram propriedades desconhecidas.
Agora imagine remover:
customerName
ou alterar:
total
decimal
para:
total
objeto com moeda
Isso pode quebrar consumidores.
A primeira pergunta deve ser:
a mudança é compatível com o contrato existente?
Versionamento é uma ferramenta para gerenciar incompatibilidades, não uma obrigação para cada deploy.
Estratégias de versionamento
Existem várias formas comuns.
Segmento de URL
/api/v1/orders
/api/v2/orders
Prós:
explícito
fácil de visualizar
fácil de rotear
Contras:
versão vira parte da URL do recurso
pode gerar duplicação de rotas
Query string
/api/orders?api-version=1.0
Prós:
URL base permanece estável
fácil de adicionar em chamadas
Contras:
versão fica menos visível
interações com cache precisam ser bem entendidas
Header
X-Api-Version: 1.0
Prós:
URL limpa
versão tratada como metadado
Contras:
menos óbvio em navegação manual
mais difícil de descobrir olhando apenas a URL
Media type
Accept: application/vnd.logby.orders.v2+json
Pode ser poderoso, mas aumenta complexidade operacional e de documentação.
Não existe formato universalmente melhor.
O importante é estabelecer uma política.
O projeto ASP.NET API Versioning fornece bibliotecas para adicionar semântica de versionamento a Controllers e Minimal APIs.
Atualmente os pacotes modernos usam o prefixo:
Asp.Versioning.*
Por exemplo, suporte a Controllers fica no ecossistema Asp.Versioning.Mvc.
Uma configuração conceitual pode ser:
builder.Services
.AddApiVersioning(options =>
{
options.ReportApiVersions = true;
})
.AddMvc();
Antes de adicionar uma biblioteca de versionamento, decida:
quando uma nova versão nasce?
quanto tempo uma versão antiga vive?
como avisamos depreciação?
quem mantém duas implementações?
como documentamos cada contrato?
como observamos uso de versões antigas?
Sem política, versionamento apenas multiplica código.
Evite versionamento por copiar o sistema inteiro
Uma abordagem perigosa:
Controllers/V1
Services/V1
Repositories/V1
Domain/V1
Controllers/V2
Services/V2
Repositories/V2
Domain/V2
Depois:
V3
Em pouco tempo temos três sistemas quase iguais.
Frequentemente, a versão pertence à fronteira HTTP, não ao domínio inteiro.
Podemos ter:
contrato v1
|
v
mapping
|
v
mesma aplicação e domínio
contrato v2
|
v
mapping diferente
|
v
mesma aplicação e domínio
Claro que mudanças profundas podem exigir comportamento diferente.
Mas não clone arquitetura automaticamente.
Compatibilidade é uma habilidade de design
Imagine este enum no contrato:
{
"status": "pending"
}
Depois adicionamos:
partially_paid
Clientes antigos fazem:
switch (status) {
case "pending":
case "paid":
case "cancelled":
break;
default:
throw new Error("Unexpected status");
}
A adição que parecia compatível pode quebrar consumidores rígidos.
Contratos distribuídos exigem pensar em evolução tolerante.
Algumas práticas úteis:
documentar campos opcionais
evitar significados ambíguos
não reutilizar um campo com nova semântica
considerar consumidores que atualizam lentamente
testar contratos
medir uso de versões antigas
"Adicionar campo nunca quebra" e "adicionar valor de enum nunca quebra" são simplificações perigosas.
Compatibilidade depende do comportamento real dos consumidores.
OpenAPI faz parte da experiência do contrato
Embora uma aula específica possa aprofundar documentação, vale um princípio agora.
Uma API deveria conseguir descrever:
rotas
parâmetros
request bodies
status codes
response schemas
media types
Atributos como:
[ProducesResponseType<OrderResponse>(
StatusCodes.Status200OK)]
[ProducesResponseType(
StatusCodes.Status404NotFound)]
ajudam ferramentas de API Explorer e OpenAPI a representar contratos mais corretamente.
Não faça documentação dizer:
200 apenas
quando o endpoint também retorna:
400
404
409
Documentação incorreta é pior que documentação ausente porque cria falsa confiança.
Um controller não deveria conter o sistema inteiro
Este código é um alerta:
[HttpPost]
public async Task<IActionResult> Create(
CreateOrderRequest request)
{
// valida usuário
// consulta banco
// calcula imposto
// verifica estoque
// cobra pagamento
// envia e-mail
// publica evento
// gera PDF
// escreve log
}
O controller é uma fronteira HTTP.
Uma responsabilidade saudável:
receber contrato HTTP
|
v
traduzir entrada
|
v
chamar aplicação
|
v
traduzir resultado
|
v
resposta HTTP
Não significa que controller precisa ter exatamente três linhas.
Significa que regras de negócio não deveriam existir ali apenas porque foi o lugar mais fácil de começar.
Quando chegarmos a arquitetura, CQRS e Vertical Slice, vamos explorar formas de organizar essa fronteira.
Cuidado com abstrações HTTP vazando para o domínio
Evite:
public sealed class OrderService
{
public IActionResult Cancel(Guid id)
{
// ...
}
}
Agora a camada de negócio conhece:
IActionResult
HTTP status codes
ASP.NET Core
Fica mais difícil reutilizar ou testar a lógica fora da camada HTTP.
Uma alternativa:
public enum CancelOrderResult
{
Success,
NotFound,
AlreadyPaid
}
Serviço:
public CancelOrderResult Cancel(Guid id)
{
var order = GetById(id);
if (order is null)
{
return CancelOrderResult.NotFound;
}
if (order.Status == OrderStatus.Paid)
{
return CancelOrderResult.AlreadyPaid;
}
order.Cancel();
return CancelOrderResult.Success;
}
Controller:
var result =
_orderService.Cancel(id);
return result switch
{
CancelOrderResult.Success =>
NoContent(),
CancelOrderResult.NotFound =>
NotFound(),
CancelOrderResult.AlreadyPaid =>
Problem(
statusCode:
StatusCodes.Status409Conflict,
title:
"Order cannot be cancelled",
detail:
"Paid orders cannot be cancelled.",
type:
"https://api.example.com/problems/order-cannot-be-cancelled"),
_ =>
throw new InvalidOperationException(
"Unexpected cancellation result.")
};
Agora o domínio da operação conhece:
sucesso
não encontrado
pedido já pago
A camada HTTP decide:
204
404
409
Essa separação será muito importante no restante da série.
Criando um contrato completo de pedidos
Vamos juntar os conceitos principais.
Controller:
using Logby.Store.Api.Contracts;
using Logby.Store.Api.Domain;
using Logby.Store.Api.Services;
using Microsoft.AspNetCore.Mvc;
namespace Logby.Store.Api.Controllers;
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
private readonly OrderService _orderService;
public OrdersController(
OrderService orderService)
{
_orderService = orderService;
}
[HttpGet]
[ProducesResponseType<PagedResponse<OrderResponse>>(
StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(
StatusCodes.Status400BadRequest)]
public ActionResult<PagedResponse<OrderResponse>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
if (page < 1)
{
ModelState.AddModelError(
nameof(page),
"Page must be greater than zero.");
}
if (pageSize is < 1 or > 100)
{
ModelState.AddModelError(
nameof(pageSize),
"Page size must be between 1 and 100.");
}
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
var result =
_orderService.GetPage(
page,
pageSize);
return Ok(
new PagedResponse<OrderResponse>(
result.Items
.Select(Map)
.ToArray(),
result.Page,
result.PageSize,
result.TotalItems,
result.TotalPages));
}
[HttpGet("{id:guid}")]
[ProducesResponseType<OrderResponse>(
StatusCodes.Status200OK)]
[ProducesResponseType(
StatusCodes.Status404NotFound)]
public ActionResult<OrderResponse> GetById(
Guid id)
{
var order =
_orderService.GetById(id);
if (order is null)
{
return NotFound();
}
return Ok(Map(order));
}
[HttpPost]
[ProducesResponseType<OrderResponse>(
StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(
StatusCodes.Status400BadRequest)]
public ActionResult<OrderResponse> Create(
CreateOrderRequest request)
{
var order =
_orderService.Create(
request.CustomerName,
request.Total);
return CreatedAtAction(
nameof(GetById),
new
{
id = order.Id
},
Map(order));
}
[HttpPost("{id:guid}/cancellations")]
[ProducesResponseType(
StatusCodes.Status204NoContent)]
[ProducesResponseType(
StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(
StatusCodes.Status409Conflict)]
public IActionResult Cancel(Guid id)
{
var result =
_orderService.Cancel(id);
return result switch
{
CancelOrderResult.Success =>
NoContent(),
CancelOrderResult.NotFound =>
NotFound(),
CancelOrderResult.AlreadyPaid =>
Problem(
statusCode:
StatusCodes.Status409Conflict,
title:
"Order cannot be cancelled",
detail:
"Paid orders cannot be cancelled.",
type:
"https://api.example.com/problems/order-cannot-be-cancelled"),
_ =>
throw new InvalidOperationException(
"Unexpected cancellation result.")
};
}
private static OrderResponse Map(
Order order)
{
return new OrderResponse(
order.Id,
order.CustomerName,
order.Total,
order.Status
.ToString()
.ToLowerInvariant(),
order.CreatedAt);
}
}
Esse controller já mostra vários princípios:
rotas orientadas a recursos
métodos HTTP coerentes
DTOs separados
status codes explícitos
Problem Details
paginação
validação
resultado de negócio separado de HTTP
contratos de resposta documentáveis
Ainda existem muitas melhorias possíveis.
Mas agora temos uma API projetada como contrato, não apenas métodos expostos pela internet.
O que muda em Minimal APIs no .NET 10?
Na próxima aula vamos aprofundar isso, mas existe uma evolução importante.
ASP.NET Core 10 adicionou suporte integrado de validação para Minimal APIs através dos serviços de validation e filtros gerados para os endpoints quando configurados.
Isso reduz uma diferença histórica entre controllers com [ApiController] e Minimal APIs em cenários de validação.
Mesmo assim, o princípio permanece igual:
framework pode executar validação
você continua responsável pelo contrato
Não importa se o código é:
[HttpPost]
public IActionResult Create(...)
ou:
app.MapPost(...)
Ainda precisamos decidir:
qual recurso?
qual método?
qual status?
qual schema?
qual erro?
qual compatibilidade?
Minimal API não significa API sem design.
Como isso se conecta com IA
Agora imagine nossos futuros endpoints.
Chat
POST /api/conversations/{id}/messages
Perguntas:
Retornamos 200 ou 201?
A mensagem do usuário virou um recurso?
A resposta do assistente é síncrona?
Existe streaming?
Como cancelamos a geração?
O cliente pode repetir a mesma mensagem?
Como evitamos cobrança duplicada?
Upload para RAG
POST /api/documents
Talvez o correto seja:
201
documento criado imediatamente
processamento continua
ou
202
job aceito
documento ainda não está pronto
São contratos diferentes.
Tool calling
Um agente executa:
cancelar pedido
transferir dinheiro
abrir chamado
Agora idempotência deixa de ser detalhe.
Um retry automático mal projetado pode repetir uma ação real.
Rate limiting
Chamadas a modelos têm custo.
429 Too Many Requests passa a representar uma parte importante do contrato.
Cancelamento
Se o usuário fecha o chat:
CancellationToken
|
v
cancelar busca
cancelar HTTP externo
cancelar streaming quando possível
Isso pode economizar recursos e dinheiro.
Erros
Um provedor de modelo pode retornar:
timeout
rate limit
indisponibilidade
conteúdo bloqueado
contexto excedido
Não devemos simplesmente devolver a exceção do SDK para o cliente.
Precisamos traduzir infraestrutura em um contrato público estável.
Ou seja:
engenharia de APIs continua sendo engenharia de IA.
Armadilhas comuns em projetos reais
1. Retornar 200 para tudo
Isso obriga cada consumidor a interpretar um segundo protocolo inventado dentro do JSON.
Use a semântica HTTP de forma consistente.
2. Usar entidade de banco como contrato público
Isso acopla API, persistência e domínio.
Uma migration interna pode virar breaking change externo.
3. Permitir binding em propriedades sensíveis
Over-posting pode permitir que clientes controlem campos que deveriam pertencer ao servidor.
Use request DTOs específicos.
4. Colocar regras de negócio em DataAnnotations
DataAnnotations são úteis para validação de contrato.
Não transforme atributos em um motor de domínio completo.
5. Colocar IActionResult dentro dos serviços de negócio
Isso espalha ASP.NET Core para camadas que não deveriam conhecer HTTP.
Modele resultados de aplicação e faça a tradução na borda.
6. Não limitar paginação
pageSize=10000000 pode virar uma negação de serviço criada pela própria API.
Defina limites.
7. Paginar sem ordenação determinística
Resultados podem repetir ou desaparecer entre páginas.
Defina ordenação estável.
8. Usar POST para tudo
POST é flexível, mas isso não significa que os outros métodos não tenham valor.
Semântica ajuda clientes, caches, proxies, documentação e manutenção.
9. Achar que DELETE sempre precisa retornar 200 com o objeto
204 No Content pode representar corretamente sucesso sem corpo.
Escolha pelo contrato, não por hábito.
10. Ignorar retry e idempotência
Em rede, "não recebi resposta" não significa "o servidor não executou".
Esse detalhe causa duplicação de pagamentos, pedidos e mensagens.
11. Versionar cedo demais
Criar /v2 para adicionar um campo opcional pode multiplicar manutenção sem necessidade.
Analise compatibilidade primeiro.
12. Versionar tarde demais
Alterar um contrato incompatível silenciosamente pode quebrar consumidores em produção.
Conheça quem depende da API.
13. Ignorar CancellationToken
Trabalho continua mesmo quando ninguém mais precisa do resultado.
Em IA isso pode gerar custo financeiro direto.
14. Expor mensagens internas de exceção
Detalhes internos não pertencem ao contrato público.
Use Problem Details controlado e logging interno.
15. Criar envelopes genéricos por padrão
ApiResponse<T> para absolutamente tudo pode adicionar complexidade sem benefício.
Use apenas quando existir um motivo concreto.
Guia de decisão
Use GET quando
A intenção é obter uma representação sem solicitar mudança de estado de negócio.
Exemplos:
GET /orders
GET /orders/{id}
Use POST quando
A operação possui semântica de processamento, criação subordinada ou comando que não se encaixa naturalmente em substituição idempotente.
Exemplos:
POST /orders
POST /orders/{id}/cancellations
POST /payments
Analise idempotência quando efeitos não podem ser duplicados.
Use PUT quando
O contrato representa criação ou substituição completa do recurso alvo com semântica idempotente.
Não trate PUT automaticamente como "update qualquer coisa".
Use PATCH quando
Existe uma atualização parcial com formato e regras claramente definidos.
Não envie um objeto parcial arbitrário sem definir o significado de ausência, null e propriedades não modificáveis.
Use DELETE quando
A semântica do recurso realmente permite remoção.
Não confunda verbo HTTP com autorização do domínio para apagar dados.
Retorne 201 quando
Um novo recurso identificável foi criado e faz sentido informar sua localização.
Retorne 202 quando
O trabalho foi aceito, mas ainda não foi concluído.
Excelente para processamento assíncrono demorado.
Retorne 204 quando
A operação foi bem-sucedida e não há conteúdo útil para devolver.
Use Problem Details quando
Você precisa de um formato consistente e legível por máquina para erros HTTP.
Crie DTOs específicos quando
Request e response possuem responsabilidades diferentes, ou quando expor o modelo interno criaria acoplamento ou risco.
Na maioria das APIs de vida longa, isso é uma escolha saudável.
Considere versionamento quando
Existe uma mudança incompatível que precisa coexistir com consumidores antigos.
Não versione automaticamente cada evolução compatível.
Propague CancellationToken quando
A operação assíncrona pode parar de forma segura quando a requisição deixa de ser necessária.
Isso inclui banco de dados, HTTP externo, storage e chamadas de IA.
Exercício completo da aula
Expanda Logby.Store.Api.
Parte 1: contratos
Crie:
CreateOrderRequest
OrderResponse
PagedResponse<T>
Não retorne Order diretamente pela API.
Parte 2: endpoints
Implemente:
GET /api/orders
GET /api/orders/{id}
POST /api/orders
POST /api/orders/{id}/cancellations
DELETE /api/orders/{id}
Parte 3: status codes
Garanta:
GET encontrado
200
GET inexistente
404
POST criação
201 + Location
cancelamento bem-sucedido
204
cancelamento de pedido pago
409 + Problem Details
DELETE bem-sucedido
204
Parte 4: validação
CreateOrderRequest precisa validar:
CustomerName
obrigatório
2 a 120 caracteres
Total
maior que zero
Teste uma requisição inválida e examine a resposta.
Parte 5: paginação
Implemente:
GET /api/orders?page=1&pageSize=20
Limite:
pageSize máximo = 100
Garanta ordenação determinística.
Parte 6: cancelamento
Crie uma operação assíncrona simulada:
public async Task<Order?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken)
{
await Task.Delay(
TimeSpan.FromSeconds(2),
cancellationToken);
return GetById(id);
}
Propague o token desde o controller.
Depois cancele a requisição pelo cliente e observe o comportamento.
Parte 7: Problem Details
Padronize o conflito de cancelamento:
type
title
status
detail
Não retorne stack trace.
Parte 8: análise de breaking changes
Pegue:
{
"id": "...",
"customerName": "Maria",
"total": 100
}
Classifique as mudanças abaixo como potencialmente compatíveis ou incompatíveis, justificando cada decisão:
adicionar status
remover customerName
renomear total para amount
alterar total de número para objeto
adicionar novo valor ao status
mudar 404 para 200 com null
Não existe resposta puramente mecânica em todos os casos.
Considere o comportamento dos consumidores.
Checklist antes de seguir
Antes da próxima aula, você deve conseguir explicar:
- por que uma API é um contrato;
- por que HTTP não deve ser tratado apenas como túnel para métodos;
- o significado de método seguro;
- a diferença entre segurança e idempotência;
- por que GET não deveria executar ações de negócio destrutivas;
- quando 200, 201, 202 e 204 representam resultados diferentes;
- quando 400, 404 e 409 comunicam problemas diferentes;
- por que DTO e entidade de domínio não precisam ser a mesma classe;
- o que é over-posting;
- o papel do model binding;
- a diferença entre binding, validação de entrada e regra de negócio;
- por que Problem Details melhora consistência;
- por que pagination precisa de limites e ordenação;
- a diferença conceitual entre offset e cursor pagination;
- por que PUT e PATCH não são sinônimos;
- como retry se conecta à idempotência;
- quando 202 é melhor do que manter uma requisição longa;
- por que CancellationToken precisa ser propagado;
- por que cancelamento não é rollback;
- quando versionar uma API;
- por que adicionar uma versão não deveria significar clonar toda a arquitetura;
- como esses conceitos se conectam diretamente com aplicações de IA.
Se esses pontos estiverem claros, estamos prontos para discutir uma das formas mais produtivas de implementar esses contratos no ASP.NET Core moderno.
Conclusão
Uma Web API profissional não é definida pela quantidade de endpoints que possui.
Ela é definida pela clareza do contrato.
O cliente precisa entender:
o que está sendo solicitado
qual recurso está sendo acessado
qual efeito a operação possui
qual resposta representa sucesso
como erros são representados
se a operação pode ser repetida
como coleções grandes são navegadas
como contratos evoluem
como uma operação pode ser cancelada
HTTP já possui uma linguagem para grande parte disso:
métodos
status codes
headers
representações
semântica de recursos
Nosso trabalho é usar essa linguagem conscientemente.
Nesta aula construímos uma base que continuará válida independentemente do estilo de implementação.
Podemos usar:
Controllers
Minimal APIs
Vertical Slice
CQRS
Microservices
Os princípios continuam os mesmos.
Uma API ruim não se torna boa porque foi reescrita com Minimal APIs.
Uma API bem projetada também não depende de Controllers para continuar bem projetada.
Na próxima aula vamos entrar exatamente nesse ponto:
Minimal APIs no ASP.NET Core 10.
Vamos sair do exemplo básico de:
app.MapGet("/", () => "Hello");
e construir uma aplicação organizada de verdade, cobrindo route groups, typed results, parameter binding, validação integrada do .NET 10, endpoint filters, organização por features, metadata, OpenAPI, Dependency Injection e critérios práticos para decidir entre Minimal APIs e Controllers.
Fontes
- https://datatracker.ietf.org/doc/html/rfc9110: especificação de semântica HTTP, incluindo métodos seguros, idempotência, status codes e significado das operações.
- https://datatracker.ietf.org/doc/rfc9457/: especificação atual de Problem Details para representação estruturada de erros em APIs HTTP.
- https://learn.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-10.0: documentação oficial sobre criação de Web APIs com Controllers no ASP.NET Core 10 e comportamento do atributo
ApiController. - https://learn.microsoft.com/en-us/aspnet/core/web-api/action-return-types?view=aspnetcore-10.0: referência sobre tipos de retorno de actions,
ActionResult<T>, status codes e metadata de respostas. - https://learn.microsoft.com/en-us/aspnet/core/mvc/models/model-binding?view=aspnetcore-10.0: documentação sobre model binding e transformação de dados HTTP em parâmetros e modelos .NET.
- https://learn.microsoft.com/en-us/aspnet/core/mvc/models/validation?view=aspnetcore-10.0: referência sobre validação de modelos, DataAnnotations e integração de erros de validação com APIs.
- https://learn.microsoft.com/en-us/aspnet/core/fundamentals/error-handling-api?view=aspnetcore-10.0: orientação oficial para tratamento de erros e Problem Details em APIs ASP.NET Core.
- https://learn.microsoft.com/en-us/aspnet/core/fundamentals/use-http-context?view=aspnetcore-10.0: documentação sobre
HttpContext.RequestAbortede cancelamento associado ao ciclo de vida da requisição. - https://learn.microsoft.com/en-us/aspnet/core/performance/timeouts?view=aspnetcore-10.0: documentação oficial sobre request timeouts e cancelamento de requisições no ASP.NET Core.
- https://github.com/dotnet/aspnet-api-versioning: projeto mantido pela .NET Foundation para versionamento de APIs em ASP.NET, incluindo suporte a Controllers e Minimal APIs.