A API da Livraria: recursos, verbos, DTOs e concorrência sobre HTTP

A API da Livraria: recursos, verbos, DTOs e concorrência sobre HTTP

O contrato HTTP da Livraria: recursos e verbos com semântica de segurança e idempotência, DTOs contra overposting, MapGroup e TypedResults, paginação, 201 com Location, ETag e If-Match contra a atualização perdida, DELETE com 204 e a venda como POST de pedido.
Linguagem C#

• • 20 min de leitura

Uma API é um contrato público. Neste artigo desenhamos o contrato da Livraria com cuidado: o que é exposto, como cada verbo se comporta e o que acontece quando dois clientes editam o mesmo livro ao mesmo tempo.

Recursos, não procedimentos

O erro mais comum em APIs HTTP é tratá-las como chamadas de função remota: POST /criarLivro, POST /obterProduto, POST /venderProduto. O HTTP já tem um vocabulário, e usá-lo traz benefícios de graça: caches sabem que GET é seguro, proxies sabem que PUT pode ser repetido e clientes genéricos entendem o que um 404 significa. A ideia vem da tese de Roy Fielding sobre REST (2000). Não precisamos seguir a tese à risca, mas vale a pena falar a língua do protocolo.

O desenho da Livraria:

Verbo Rota O que faz Sucesso
GET /produtos lista paginada 200
GET /produtos/{id} um produto 200 + ETag, ou 404
POST /produtos/livros cadastra um livro 201 + Location
PUT /produtos/{id} altera título e preço 200, ou 412/428
DELETE /produtos/{id} remove 204, ou 404
POST /pedidos registra uma venda 201, ou 409

Três propriedades do RFC 9110 orientam as decisões:

  • Seguro significa que o método não altera o estado. GET e HEAD são seguros.
  • Idempotente significa que repetir a requisição tem o mesmo efeito que fazê-la uma vez. GET, PUT e DELETE são idempotentes. POST não é.
  • Cacheável vale principalmente para GET.

Idempotência não quer dizer "mesma resposta". Um segundo DELETE no mesmo recurso devolve 404 em vez de 204, mas o estado do servidor é o mesmo depois da primeira chamada e depois da décima. É isso que permite a um cliente repetir um DELETE com segurança quando a rede cai no meio.

A venda é modelada como a criação de um pedido (POST /pedidos), e não como um "verbo" /produtos/{id}/vender. O pedido é um substantivo com identidade, e isso casa com o que fizemos em "EF Core: Relacionamentos e Migrations", onde ItemPedido guarda a fotografia do preço.

Por que não devolver a entidade

No artigo anterior o endpoint devolvia Produto direto. Há quatro problemas nisso.

  1. Acoplamento. O JSON passa a ser o formato interno do domínio. Renomear uma propriedade em Produto quebra clientes que você nem conhece. O contrato público e o modelo interno precisam poder evoluir em ritmos diferentes.
  2. Vazamento. Tudo o que é público aparece no JSON: campos internos, o token de concorrência, coleções de navegação. Com o EF, uma navegação carregada pode criar um ciclo (Pedido → Itens → Pedido) e quebrar a serialização.
  3. Overposting. Se o POST aceitasse um Livro desserializado, o cliente poderia mandar "id": 7 ou "estoque": 100000 e o binder preencheria esses campos. Esse é o caso de mass assignment que já causou incidentes famosos em outras plataformas.
  4. Invariantes. Para desserializar uma entidade, o System.Text.Json precisa de setters ou de um construtor compatível. Isso obriga a abrir a porta que o domínio fecha com cuidado desde "Propriedades e Encapsulamento".

A solução é ter tipos próprios para a fronteira HTTP, os DTOs (data transfer objects). Em C# moderno, records posicionais servem muito bem para isso:

namespace Livraria.Api.Contratos;

public sealed record ProdutoResposta(
    int Id,
    string Tipo,
    string Titulo,
    decimal Preco,
    int? Estoque,                  // só livros impressos têm estoque
    string? Autor,
    string? Isbn,
    IReadOnlyList<string> Generos);

public sealed record NovoLivro(
    string Titulo,
    string Autor,
    string Isbn,
    decimal Preco,
    int PesoGramas,
    int Estoque,
    List<string>? Generos);

public sealed record AlteracaoProduto(string Titulo, decimal Preco);

public sealed record PaginaDe<T>(
    IReadOnlyList<T> Itens, int Pagina, int Tamanho, int Total)
{
    public int TotalDePaginas => (int)Math.Ceiling(Total / (double)Tamanho);
}

Note que AlteracaoProduto não tem estoque. O estoque muda por venda ou por reposição, que são operações com regras próprias, nunca por um PUT genérico. O DTO de entrada é o lugar onde o contrato diz, pelo tipo, o que o cliente pode e não pode tocar.

O mapeamento é um método de extensão escrito à mão:

public static class MapeamentoDeProdutos
{
    public static ProdutoResposta ParaResposta(this Produto produto) => produto switch
    {
        LivroImpresso l => new ProdutoResposta(l.Id, l.Formato, l.Titulo, l.Preco, l.Estoque,
                                               l.Autor?.Nome, l.Isbn?.Valor, l.Generos),
        _               => new ProdutoResposta(produto.Id, produto.Formato, produto.Titulo, produto.Preco,
                                               null, produto.Autor?.Nome, null, produto.Generos)
    };
}

Mapeamento manual parece trabalhoso, mas é explícito, o compilador verifica e ninguém se surpreende com ele. Bibliotecas como o AutoMapper foram populares por anos. Desde 2025 o AutoMapper passou a exigir licença comercial para uso corporativo, e boa parte da comunidade migrou para mapeamento manual ou para geradores em tempo de compilação como o Mapperly, que aplica o que vimos em "Source Generators".

Organizando os endpoints: MapGroup e métodos nomeados

Lambdas enormes no Program.cs não escalam. Agrupamos as rotas de produtos num arquivo próprio e trocamos as lambdas por métodos estáticos nomeados, que são mais fáceis de ler, testar e depurar:

namespace Livraria.Api.Endpoints;

public static class EndpointsDeProdutos
{
    public static RouteGroupBuilder MapearProdutos(this IEndpointRouteBuilder rotas)
    {
        var grupo = rotas.MapGroup("/produtos").WithTags("Produtos");

        grupo.MapGet("/", Listar);
        grupo.MapGet("/{id:int}", Obter).WithName("ObterProduto");
        grupo.MapPost("/livros", CriarLivro);
        grupo.MapPut("/{id:int}", Atualizar);
        grupo.MapDelete("/{id:int}", Remover);

        return grupo;
    }

    // métodos abaixo
}

MapGroup aplica o prefixo e qualquer metadado (tags, autorização, filtros) a todas as rotas do grupo. No Program.cs fica só app.MapearProdutos();.

Há duas formas de construir respostas. Results.Ok(...) devolve IResult, e o tipo concreto da resposta se perde. TypedResults.Ok(...) devolve Ok<T>. Combinado com a união Results<T1, T2, ...>, a assinatura do método passa a documentar todas as respostas possíveis, e o compilador reclama se você devolver algo fora da lista. Como veremos no próximo artigo, o gerador de OpenAPI lê esses tipos e descreve as respostas sozinho.

O repositório ganha operações

O IRepositorioProdutos passa a ter as operações de que a API precisa:

public interface IRepositorioProdutos
{
    Task<Produto?> ObterAsync(int id, CancellationToken ct);
    Task<(IReadOnlyList<Produto> Itens, int Total)> ListarAsync(
        int pagina, int tamanho, CancellationToken ct);
    Task AdicionarAsync(Produto produto, CancellationToken ct);
    Task AtualizarAsync(Produto produto, CancellationToken ct);
    Task<bool> RemoverAsync(int id, CancellationToken ct);
}

A implementação no EF, que atende PostgreSQL e MySQL:

public async Task<(IReadOnlyList<Produto> Itens, int Total)> ListarAsync(
    int pagina, int tamanho, CancellationToken ct)
{
    var consulta = _db.Produtos.AsNoTracking().Include(p => p.Autor);   // a resposta mostra o nome do autor

    var total = await consulta.CountAsync(ct);
    var itens = await consulta
        .OrderBy(p => p.Id)                    // ordem estável é obrigatória
        .Skip((pagina - 1) * tamanho)
        .Take(tamanho)
        .ToListAsync(ct);

    return (itens, total);
}

public async Task<bool> RemoverAsync(int id, CancellationToken ct) =>
    await _db.Produtos.Where(p => p.Id == id).ExecuteDeleteAsync(ct) > 0;

O OrderBy não é detalhe. Sem ele, o SQL não garante ordem, e a página 2 pode repetir itens da página 1 ou pular outros. ExecuteDeleteAsync apaga com um único DELETE ... WHERE, sem carregar a entidade, e devolve quantas linhas foram afetadas. É assim que sabemos se deve ser 204 ou 404.

Paginação por Skip/Take (offset) é simples, mas o banco precisa percorrer e descartar todas as linhas anteriores, e o custo cresce com o número da página. Para listas longas, a alternativa é a paginação por keyset (WHERE Id > @ultimoId ORDER BY Id LIMIT @n), que custa sempre o mesmo. Ela aparece nos exercícios.

A versão Mongo segue o padrão de "MongoDB Avançado": CountDocumentsAsync com filtro vazio, e depois Find(...).SortBy(p => p.Id).Skip(...).Limit(...).

Leitura: listagem e obtenção

static async Task<Ok<PaginaDe<ProdutoResposta>>> Listar(
    IRepositorioProdutos repositorio,
    CancellationToken ct,
    int pagina = 1,
    int tamanho = 20)
{
    pagina = Math.Max(pagina, 1);
    tamanho = Math.Clamp(tamanho, 1, 100);   // nunca deixe o cliente pedir 1 milhão

    var (itens, total) = await repositorio.ListarAsync(pagina, tamanho, ct);

    return TypedResults.Ok(new PaginaDe<ProdutoResposta>(
        itens.Select(p => p.ParaResposta()).ToList(), pagina, tamanho, total));
}

static async Task<Results<Ok<ProdutoResposta>, NotFound>> Obter(
    int id,
    IRepositorioProdutos repositorio,
    HttpResponse resposta,
    CancellationToken ct)
{
    var produto = await repositorio.ObterAsync(id, ct);
    if (produto is null)
        return TypedResults.NotFound();

    var corpo = produto.ParaResposta();
    resposta.Headers.ETag = Etags.De(corpo);
    return TypedResults.Ok(corpo);
}

Parâmetros com valor padrão viram parâmetros opcionais de query string: GET /produtos?pagina=3 funciona, e GET /produtos também. O teto em tamanho é defesa básica. Sem ele, um único cliente descuidado ou malicioso faz o servidor serializar a tabela inteira.

O cabeçalho ETag é assunto da seção sobre o PUT.

Criação: POST, 201 e Location

static async Task<CreatedAtRoute<ProdutoResposta>> CriarLivro(
    NovoLivro dados,
    IRepositorioProdutos repositorio,
    CancellationToken ct)
{
    var livro = new LivroImpresso(dados.Titulo, new Autor(dados.Autor), dados.Preco,
                                  dados.PesoGramas, dados.Estoque);
    livro.DefinirIsbn(new Isbn(dados.Isbn));
    livro.ClassificarEm([.. dados.Generos ?? []]);

    await repositorio.AdicionarAsync(livro, ct);

    return TypedResults.CreatedAtRoute(
        livro.ParaResposta(), "ObterProduto", new { id = livro.Id });
}

O status 201 Created vem com o cabeçalho Location apontando para o recurso novo. CreatedAtRoute monta essa URL a partir da rota nomeada "ObterProduto". Se o prefixo do grupo mudar, o link continua correto, e é por isso que demos um nome a essa rota com WithName. O Id só existe depois de AdicionarAsync. Ele é gerado pelo banco nos provedores relacionais e pelo contador no Mongo, como fizemos em "MongoDB: o Modelo de Documentos e o Driver Oficial". O domínio é o das aulas anteriores: LivroImpresso, com o autor como entidade e o ISBN como objeto de valor. Para manter o exemplo curto, o autor é criado junto com o livro; num cadastro real, um autor que já existe precisa ser buscado antes, porque o índice único de nomes de "EF Core: Relacionamentos e Migrations" recusaria a duplicata.

Por enquanto, se o cliente mandar um título vazio, o construtor de LivroImpresso lança ArgumentException, e a API responde 500. A culpa é do cliente, mas a resposta sugere um defeito do servidor. Vamos resolver isso no próximo artigo com validação e ProblemDetails.

Atualização: PUT, ETag e If-Match

O problema clássico é a atualização perdida (lost update):

  1. Ana abre o livro 7 no painel administrativo. O preço é R$ 50.
  2. Bruno abre o mesmo livro.
  3. Ana corrige o título e salva.
  4. Bruno muda o preço para R$ 45 e salva, reenviando o título antigo que estava na tela dele.

A correção de Ana some sem que ninguém perceba. A concorrência otimista que implementamos em "EF Core com Desempenho" protege o banco contra escritas simultâneas de milissegundos. Aqui o intervalo entre ler e escrever dura minutos e atravessa duas requisições HTTP diferentes. O HTTP tem um mecanismo próprio para esse caso: requisições condicionais.

  • Todo GET devolve um ETag, uma impressão digital da representação.
  • O cliente reenvia esse valor no cabeçalho If-Match ao fazer o PUT.
  • Se o recurso mudou nesse meio-tempo, o ETag atual é outro, e o servidor responde 412 Precondition Failed sem alterar nada.
  • Se o cliente não mandou If-Match, o servidor pode exigir o cabeçalho com 428 Precondition Required (RFC 6585).
using System.Security.Cryptography;
using System.Text.Json;

public static class Etags
{
    public static string De(ProdutoResposta r)
    {
        var bytes = JsonSerializer.SerializeToUtf8Bytes(r);
        var hash = Convert.ToHexString(SHA256.HashData(bytes));
        return $"\"{hash[..16]}\"";            // ETags vão entre aspas
    }
}
static async Task<Results<Ok<ProdutoResposta>, NotFound, StatusCodeHttpResult>> Atualizar(
    int id,
    AlteracaoProduto dados,
    HttpRequest requisicao,
    HttpResponse resposta,
    IRepositorioProdutos repositorio,
    CancellationToken ct)
{
    var produto = await repositorio.ObterAsync(id, ct);
    if (produto is null)
        return TypedResults.NotFound();

    var ifMatch = requisicao.Headers.IfMatch.ToString();
    if (string.IsNullOrEmpty(ifMatch))
        return TypedResults.StatusCode(StatusCodes.Status428PreconditionRequired);

    if (ifMatch != Etags.De(produto.ParaResposta()))
        return TypedResults.StatusCode(StatusCodes.Status412PreconditionFailed);

    produto.AlterarDados(dados.Titulo, dados.Preco);   // método de domínio, com invariantes

    try
    {
        await repositorio.AtualizarAsync(produto, ct);
    }
    catch (ConflitoDeConcorrenciaException)
    {
        return TypedResults.StatusCode(StatusCodes.Status412PreconditionFailed);
    }

    var corpo = produto.ParaResposta();
    resposta.Headers.ETag = Etags.De(corpo);
    return TypedResults.Ok(corpo);
}

Calcular o ETag a partir do hash da representação tem uma vantagem: funciona igual nos três bancos sem mudar nenhum esquema. Tem também um limite. Entre a comparação do If-Match e o AtualizarAsync sobra uma janela de microssegundos. Para o estoque, essa janela está coberta pelo token de concorrência de "EF Core com Desempenho", que faz o repositório lançar ConflitoDeConcorrenciaException. Para título e preço, não está. A solução completa é uma coluna de versão incrementada a cada escrita e usada ao mesmo tempo como token de concorrência do banco e como ETag. No PostgreSQL, essa coluna pode ser a coluna de sistema xmin. No MySQL e no Mongo, é um inteiro que a aplicação incrementa. O exercício 4 pede essa evolução.

O try/catch dentro do endpoint também é provisório. No próximo artigo, as exceções de domínio passam a ser traduzidas para respostas HTTP num único lugar.

Uma observação sobre PUT e PATCH. Pela semântica do HTTP, PUT substitui a representação inteira e PATCH modifica parte dela. Nosso PUT recebe todos os campos editáveis (título e preço), então é um PUT legítimo. Se um dia houver vinte campos editáveis e os clientes quiserem mandar só um, o caminho é PATCH com application/merge-patch+json (RFC 7396).

Remoção: DELETE e 204

static async Task<Results<NoContent, NotFound>> Remover(
    int id, IRepositorioProdutos repositorio, CancellationToken ct) =>
    await repositorio.RemoverAsync(id, ct)
        ? TypedResults.NoContent()
        : TypedResults.NotFound();

204 No Content diz "deu certo e não há nada a devolver". O que acontece com um produto que aparece em pedidos antigos? Nada no banco impede o DELETE: em "EF Core: Relacionamentos e Migrations", ItemPedido guarda o ProdutoId como um número, e a única chave estrangeira da tabela de itens aponta para o pedido. O histórico ficaria com itens que apontam para um produto inexistente. A decisão é de negócio. Um produto vendido costuma ser desativado (exclusão lógica com uma coluna Ativo), não apagado, porque o histórico fiscal precisa dele. O ItemPedido já guarda o preço da época justamente para não depender do produto atual.

A venda: POST /pedidos

public sealed record ItemSolicitado(int ProdutoId, int Quantidade);
public sealed record NovoPedido(List<ItemSolicitado> Itens);
public sealed record PedidoResposta(int Id, decimal Total, IReadOnlyList<ItemPedidoResposta> Itens);
public sealed record ItemPedidoResposta(int ProdutoId, int Quantidade, decimal PrecoUnitario);

public static class EndpointsDePedidos
{
    public static RouteGroupBuilder MapearPedidos(this IEndpointRouteBuilder rotas)
    {
        var grupo = rotas.MapGroup("/pedidos").WithTags("Pedidos");
        grupo.MapPost("/", Registrar);
        return grupo;
    }

    static async Task<Results<Created<PedidoResposta>, Conflict<string>>> Registrar(
        NovoPedido dados, ServicoDeVendas vendas, CancellationToken ct)
    {
        try
        {
            var pedido = await vendas.RegistrarAsync(
                dados.Itens.Select(i => (i.ProdutoId, i.Quantidade)).ToList(), ct);

            var corpo = new PedidoResposta(pedido.Numero, pedido.Total,
                pedido.Itens.Select(i => new ItemPedidoResposta(
                    i.ProdutoId, i.Quantidade, i.PrecoUnitario)).ToList());

            return TypedResults.Created($"/pedidos/{pedido.Numero}", corpo);
        }
        catch (EstoqueInsuficienteException e)
        {
            return TypedResults.Conflict(e.Message);
        }
    }
}

Toda a regra de negócio continua no ServicoDeVendas: as três tentativas diante de conflito de concorrência e o update condicional que impede vender o último exemplar duas vezes. O endpoint só traduz HTTP para chamadas de aplicação e de volta. Se um dia a Livraria receber pedidos por uma fila de mensagens, o mesmo serviço atende sem mudar uma linha. Essa é a mesma disciplina de camadas de "Um Repositório, Três Bancos".

Para estoque insuficiente escolhemos 409 Conflict: a requisição é bem formada, mas conflita com o estado atual do recurso. Há quem prefira 422 Unprocessable Content. O importante é escolher, documentar e manter a escolha em toda a API.

Um último cuidado vale para qualquer POST que mexe com dinheiro. Como POST não é idempotente, um cliente que perde a resposta por timeout e tenta de novo pode registrar dois pedidos. A solução usual é o cabeçalho Idempotency-Key, em processo de padronização pelo IETF. O cliente gera um identificador único por intenção de compra, o servidor guarda a chave junto com a resposta e devolve a mesma resposta nas repetições. Vamos precisar de um armazenamento rápido com expiração para isso, e ele chega em "Cache com Redis, HybridCache e output caching".

Testando à mão com arquivos .http

Visual Studio, Rider e VS Code (com a extensão REST Client) executam arquivos .http que ficam versionados junto com o código:

@base = http://localhost:5080

### Lista
GET {{base}}/produtos?pagina=1&tamanho=5

### Cadastra
POST {{base}}/produtos/livros
Content-Type: application/json

{
  "titulo": "Dom Casmurro",
  "autor": "Machado de Assis",
  "isbn": "9788535910667",
  "preco": 39.90,
  "pesoGramas": 350,
  "estoque": 12,
  "generos": ["romance", "clássico"]
}

### Obtém (copie o ETag da resposta)
GET {{base}}/produtos/1

### Altera com If-Match
PUT {{base}}/produtos/1
Content-Type: application/json
If-Match: "COLE-O-ETAG-AQUI"

{ "titulo": "Dom Casmurro (edição comentada)", "preco": 44.90 }

### Vende
POST {{base}}/pedidos
Content-Type: application/json

{ "itens": [ { "produtoId": 1, "quantidade": 2 } ] }

Por padrão, o System.Text.Json do ASP.NET Core usa nomes em camelCase e desserialização sem diferenciar maiúsculas de minúsculas, por isso titulo casa com Titulo.

O Program.cs agora está assim:

var builder = WebApplication.CreateBuilder(args);
// ... registro do provedor ("ASP.NET Core por dentro") ...
builder.Services.AddScoped<ServicoDeVendas>();

var app = builder.Build();

app.UseMiddleware<IdDeCorrelacao>();
app.MapearProdutos();
app.MapearPedidos();

app.Run();

A Livraria agora tem um contrato HTTP: recursos com nomes, verbos usados de acordo com a semântica, DTOs que protegem o domínio de overposting e um controle de concorrência que funciona entre requisições separadas. Esse contrato ainda falha mal quando o cliente erra, com 500 para dados inválidos e corpos de erro que variam de endpoint para endpoint, e ninguém fora do código sabe que ele existe.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um desenvolvedor propõe POST /produtos recebendo o próprio tipo LivroImpresso como parâmetro "para economizar um DTO". Descreva uma requisição maliciosa que explora essa decisão e o que ela consegue.

Ver resposta

✓ Resposta: O cliente envia {"titulo":"X","preco":0.01,"estoque":999999,"id":1}. Se o desserializador conseguir preencher essas propriedades (setters públicos ou construtor compatível), o livro nasce com estoque arbitrário e pode tentar reaproveitar um Id existente. Conforme o provedor, isso sobrescreve dados ou provoca erro de chave duplicada. Se a entidade tiver outros campos sensíveis (Ativo, token de concorrência, campos de auditoria), todos ficam expostos. O DTO NovoLivro só contém o que o cliente pode informar, e o compilador garante isso.

Exercício 2

Implemente paginação por keyset: GET /produtos?depoisDe=120&tamanho=20 devolve os 20 produtos com Id > 120 e informa ao cliente o cursor da próxima página. Por que ela não precisa de CountAsync, e qual recurso da paginação por offset se perde?

Ver resposta

✓ Resposta: No repositório: consulta.Where(p => p.Id > depoisDe).OrderBy(p => p.Id).Take(tamanho + 1). Pedir um item a mais revela se existe próxima página sem contar a tabela. A resposta traz Itens (os primeiros tamanho) e ProximoCursor (o Id do último item, ou null se não houver mais). Com um índice em Id, o banco vai direto ao ponto, e a página 5000 custa o mesmo que a primeira. Perdem-se o total de itens e a possibilidade de saltar para uma página arbitrária ("ir para a página 37"), por isso a técnica serve para rolagem infinita e para exportações, e não para paginação numerada.

Exercício 3

Explique por que o segundo DELETE /produtos/7 devolve 404 e ainda assim o DELETE é considerado idempotente. Dê um exemplo de endpoint mal desenhado que viola a idempotência do PUT.

Ver resposta

✓ Resposta: Idempotência diz respeito ao efeito no estado do servidor, não ao código de resposta. Depois do primeiro DELETE o produto 7 não existe, e depois do segundo continua não existindo, então o estado é idêntico. Um PUT que viola a idempotência é, por exemplo, PUT /produtos/7 com corpo {"ajusteDeEstoque": 5} que soma 5 ao estoque: cada repetição altera o estado de novo. Operações relativas como incrementos devem ser POST (POST /produtos/7/reposicoes), para que proxies e clientes não as repitam automaticamente.

Exercício 4

Substitua o ETag por hash por uma coluna de versão. Esboce as mudanças no domínio, no mapeamento EF e no Mongo, e explique por que isso fecha a janela entre o If-Match e a escrita.

Ver resposta

✓ Resposta: O domínio ganha public int Versao { get; private set; }, incrementada em todo método que altera o estado (AlterarDados, BaixarEstoque etc.). No EF: builder.Property(p => p.Versao).IsConcurrencyToken();, e o UPDATE gerado passa a ter WHERE Id = @id AND Versao = @versaoOriginal. No PostgreSQL também é possível mapear xmin com IsRowVersion(), deixando o banco controlar a versão. No Mongo, o ReplaceOneAsync filtra por Id e Versao lida, e zero documentos alterados significa conflito, que é o mesmo mecanismo de _versoesLidas de "Um Repositório, Três Bancos". O ETag vira $"\"{produto.Versao}\"". O endpoint aplica a versão do If-Match como versão original antes de salvar. Assim a comparação passa a acontecer dentro do próprio UPDATE, de forma atômica, e não resta janela alguma. O ConflitoDeConcorrenciaException vira 412.

Exercício 5

Um cliente móvel chama POST /pedidos, a rede cai, e ele não sabe se o pedido foi registrado. Descreva o protocolo Idempotency-Key do lado do servidor: o que guardar, por quanto tempo, e o que fazer quando uma segunda requisição com a mesma chave chega enquanto a primeira ainda está em processamento.

Ver resposta

✓ Resposta: O servidor mantém um registro por chave com o estado (em processamento ou concluída), um hash do corpo da requisição e a resposta final (status e corpo). Ao receber uma chave nova, grava o estado "em processamento" com uma operação atômica do tipo "inserir se não existir", processa e grava a resposta. Ao receber uma chave já concluída, devolve a resposta armazenada sem reprocessar. Se o hash do corpo for diferente, responde 422, porque o cliente está reutilizando a chave para outra intenção. Se a chave ainda estiver em processamento, responde 409 e o cliente tenta de novo depois. A retenção costuma ser de 24 horas, com expiração automática (TTL no Redis, índice TTL no Mongo). A inserção atômica é o ponto crítico: sem ela, duas requisições simultâneas podem registrar dois pedidos.

Comentários

Mais em Linguagem C#

Autenticação e autorização com JWT: quem é você e o que pode fazer
Autenticação e autorização com JWT: quem é você e o que pode fazer

Quem pode fazer o quê na API da Livraria: 401 contra 403, a anatomia do JWT…

Interfaces: Contratos Que Qualquer Tipo Pode Assinar
Interfaces: Contratos Que Qualquer Tipo Pode Assinar

Interfaces em C#: contratos que tipos sem parentesco assinam, várias…

Um game loop do zero com MonoGame: janela, sprites, entrada e os primeiros livros caindo
Um game loop do zero com MonoGame: janela, sprites, entrada e os primeiros livros caindo

O primeiro protótipo da Biblioteca em Apuros em MonoGame, escrito à mão…