DEV Community

Cover image for Idempotência em ingestão de pedidos no Spring Boot: o que separa o protótipo da produção
Felipe Ricarte
Felipe Ricarte

Posted on

Idempotência em ingestão de pedidos no Spring Boot: o que separa o protótipo da produção

Integrar dois sistemas via pedidos parece a tarefa mais banal do mundo: recebe um JSON, salva no banco, devolve 201. Você faz isso antes do café.

O problema nunca é esse. O problema aparece três semanas depois, em produção, quando o sistema que te envia os pedidos sofre um timeout, reenvia a mesma requisição, e você descobre que o pedido A-123456 agora existe duas vezes no seu banco — com cobrança dobrada, estoque furado e um cliente irritado.

Este artigo é sobre como receber pedidos de forma idempotente, mesmo sob concorrência, em Java + Spring Boot. É a diferença entre um endpoint que funciona na demo e um que aguenta produção.

O cenário

Um Sistema A envia pedidos para o seu serviço. Cada pedido tem um identificador de origem — vou chamar de externalOrderId. Seu serviço precisa:

  1. Receber o pedido (POST /orders).
  2. Calcular os totais.
  3. Persistir.
  4. Disponibilizar para consulta de um Sistema B.

A regra de ouro: o mesmo externalOrderId nunca pode virar dois pedidos, não importa quantas vezes o Sistema A reenvie, nem quantas instâncias do seu serviço estejam rodando.

A tentativa ingênua (que falha)

A primeira versão que todo mundo escreve é mais ou menos esta:

public Order receber(OrderRequest req) {
    Optional<Order> existente = repo.findByExternalOrderId(req.externalOrderId());
    if (existente.isPresent()) {
        return existente.get(); // já existe, retorna
    }
    Order novo = calcular(req);
    return repo.save(novo); // cria
}
Enter fullscreen mode Exit fullscreen mode

Parece correto. E funciona — até duas requisições com o mesmo externalOrderId chegarem ao mesmo tempo (duas instâncias, ou duas threads). As duas executam o findBy..., as duas não encontram nada, as duas chamam save. Resultado: pedido duplicado.

Esse é um race condition clássico. O check-then-act (verifico e depois ajo) não é atômico, então a verificação não vale nada sob concorrência.

A solução correta: deixe o banco garantir a unicidade

A regra de "um por externalOrderId" não deve viver só no código — ela tem que ser uma constraint do banco:

ALTER TABLE orders ADD CONSTRAINT uq_orders_external_id UNIQUE (external_order_id);
Enter fullscreen mode Exit fullscreen mode

Com isso, mesmo que duas transações tentem inserir o mesmo pedido simultaneamente, o banco rejeita a segunda. Aí o seu código só precisa tratar essa rejeição como "ah, já existe" — e devolver o pedido existente:

public Result receber(OrderRequest req) {
    try {
        Order novo = calcular(req);
        repo.saveAndFlush(novo);          // flush para a violação estourar AGORA
        return Result.criado(novo);        // -> 201 Created
    } catch (DataIntegrityViolationException e) {
        // outra requisição ganhou a corrida: o pedido já existe
        Order existente = repo.findByExternalOrderId(req.externalOrderId())
                              .orElseThrow();
        return Result.jaExistia(existente); // -> 200 OK
    }
}
Enter fullscreen mode Exit fullscreen mode

Repare na semântica de status:

  • 201 Created quando você criou o pedido.
  • 200 OK quando o pedido já existia (a requisição é idempotente — repetir não muda nada).

Essa distinção é importante: o Sistema A pode reenviar à vontade que o comportamento é sempre seguro e previsível. É exatamente o que se espera de uma API resiliente.

Totais: cuidado com double

Outro detalhe que parece bobo e quebra em produção: dinheiro não é double. 0.1 + 0.2 não dá 0.3 em ponto flutuante. Para totais de pedido, use BigDecimal com escala e arredondamento explícitos:

BigDecimal lineTotal = unitPrice
        .multiply(BigDecimal.valueOf(quantity))
        .setScale(2, RoundingMode.HALF_UP);

BigDecimal totalAmount = items.stream()
        .map(Item::lineTotal)
        .reduce(BigDecimal.ZERO, BigDecimal::add)
        .setScale(2, RoundingMode.HALF_UP);
Enter fullscreen mode Exit fullscreen mode

Escala 2, HALF_UP, sempre. Assim o total fecha igual ao da planilha do financeiro — e você não vira o dev do "centavo sumido".

Observabilidade: o que salva você às 3 da manhã

Quando algo der errado (e vai), você precisa rastrear uma requisição do começo ao fim. Um correlation id propagado por header e logado em tudo resolve 80% das investigações:

String correlationId = Optional.ofNullable(request.getHeader("X-Correlation-Id"))
        .orElse(UUID.randomUUID().toString());
MDC.put("correlationId", correlationId);
Enter fullscreen mode Exit fullscreen mode

Some a isso o Actuator (/actuator/health) e logs estruturados, e você consegue operar com confiança em vez de no escuro.

Recapitulando

Uma ingestão de pedidos pronta para produção precisa de:

  1. Unicidade no banco (constraint), não só no código.
  2. Tratar a violação como idempotência (201 ao criar, 200 quando já existe).
  3. BigDecimal com escala e arredondamento explícitos para dinheiro.
  4. Correlation id + observabilidade para conseguir investigar produção.

Nenhuma dessas peças é difícil isoladamente. O trabalho está em juntar tudo, testar sob concorrência e documentar as decisões para o time não desfazer depois.


Quer pular essa parte?

Eu empacotei exatamente esse padrão num template de produção em Java 21 + Spring Boot 3: ingestão idempotente sob concorrência, cálculo correto de totais, PostgreSQL + Liquibase, observabilidade, ADRs e runbook — sobe com um docker compose up e já vem com scripts que simulam os sistemas externos.

Em vez de gastar semanas montando a fundação, você parte dela.

👉 Conheça o Order Service Template

Felipe Ricarte Magalhães — Arquiteto de Software (hands-on), +17 anos em backend e integrações corporativas.

Top comments (0)