Skip to content
wnmunhozPublic

About

Backend de carteira digital em **Java 17+ / Spring Boot**, com crédito, débito, extrato e idempotência, projetado para receber tráfego concorrente sobre a mesma carteira.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Wallet API — Serviço de Carteira Digital

Backend de carteira digital em Java 17+ / Spring Boot, com crédito, débito, extrato e idempotência, projetado para receber tráfego concorrente sobre a mesma carteira.

Duas invariantes são inegociáveis, e o projeto inteiro está organizado em torno delas:

  1. O saldo nunca fica negativo.
  2. Nenhuma operação é aplicada em duplicidade, mesmo sob chamadas concorrentes ou repetidas.

Não bastava fazer o serviço funcionar — era preciso provar que ele se comporta como promete. Essa prova está em Testes, e o raciocínio por trás de cada escolha está em DECISIONS.md.


Sumário


Como rodar do zero

Pré-requisitos: JDK 17+ e Docker com Compose. Nada mais — o Maven vem pelo wrapper.

# 1. Subir o PostgreSQL
docker compose up -d db

# 2. Conferir que o banco está saudável (aguarda o healthcheck)
docker compose ps
#    wallet-db   Up (healthy)

# 3. Rodar os testes unitários (não precisam de banco)
./mvnw test

# 4. Rodar a suíte completa, incluindo integração e concorrência
./mvnw verify

# 5. Subir a aplicação
./mvnw spring-boot:run

A API sobe em http://localhost:8080.

O docker-compose.yml cria dois bancos: wallet (desenvolvimento) e wallet_test (testes de integração). Estarem separados é o que torna seguro o TRUNCATE entre testes — ele nunca alcança dados de desenvolvimento.

Variáveis de ambiente
Variável Default Descrição
DB_HOST localhost Host do PostgreSQL
DB_PORT 5432 Porta do PostgreSQL
DB_NAME wallet Banco de desenvolvimento
DB_TEST_NAME wallet_test Banco usado pelos testes
DB_USER / DB_PASSWORD wallet / wallet Credenciais
DB_POOL_SIZE 20 Tamanho máximo do pool Hikari
SERVER_PORT 8080 Porta HTTP
BRASIL_API_URL https://brasilapi.com.br Consulta de situação cadastral de CNPJ
BRASIL_API_CONNECT_TIMEOUT 2s Tempo para estabelecer a conexão
BRASIL_API_READ_TIMEOUT 3s Tempo para a resposta chegar
BRASIL_API_DEADLINE 6s Teto total da consulta, somando tentativas e esperas
BRASIL_API_MAX_ATTEMPTS 3 Teto de tentativas
BRASIL_API_BACKOFF 200ms Espera antes da 1ª retentativa; dobra a cada seguinte

A abertura de carteira depende de rede de saída. Um CNPJ só é aceito se a Receita confirmar que está ATIVA, e o serviço é fail-closed: sem essa confirmação, responde 503. Em ambiente sem egress para a internet, o POST /wallets com CNPJ passa a falhar — abertura com CPF continua funcionando, porque não consulta ninguém.

Rodando com Docker apenas no WSL (sem Docker Desktop)

Este projeto foi desenvolvido em uma máquina Windows sem Docker Desktop, com o Docker Engine dentro do WSL2 e automount = false no /etc/wsl.conf — ou seja, o daemon não conseguia ler o diretório do projeto. A solução foi sincronizar a árvore para dentro do WSL e rodar o compose de lá:

tar -cf - docker-compose.yml docker pom.xml mvnw .mvn src \
  | wsl.exe -d Debian -- bash -lc 'mkdir -p ~/wallet-dev && tar -xf - -C ~/wallet-dev'
wsl.exe -d Debian -- bash -lc 'cd ~/wallet-dev && docker compose up -d db'

O build e os testes seguem rodando no Windows: o WSL2 publica a porta 5432 e o Windows a alcança em localhost. Como os caminhos do compose são relativos, o mesmo arquivo funciona sem alteração em quem tem Docker Desktop.


Stack

Java Bytecode 17 (maven.compiler.release), compilável com JDK 17 ou superior
Spring Boot 3.5.16 — web, data-jpa, validation, security, actuator
Banco PostgreSQL 17 em container. Sem banco em memória, nem em teste
Migrations Flyway. ddl-auto=validate, nunca update
Docs springdoc-openapi 2.8.17 (Swagger UI + spec versionada)
Observabilidade Actuator + Micrometer/Prometheus, log JSON via logstash-encoder
Carga k6, com prova de consistência (resultado)
Build Maven Wrapper 3.9.9; surefire para unitários, failsafe para integração

Documentação da API

Com a aplicação de pé:

Swagger UI http://localhost:8080/swagger-ui.html
OpenAPI (JSON) http://localhost:8080/v3/api-docs
Versionado no repo docs/openapi.json

A especificação é gerada do código, não escrita à mão — um contrato mantido em paralelo divergiria dele na primeira mudança, e um contrato que mente é pior que contrato nenhum. A cópia em docs/ é o mesmo documento exportado, para quem quiser lê-lo sem subir nada.

Ambos ficam abertos, sem chave: é o que permite abrir o Swagger e explorar a API antes de ter uma credencial. OpenApiIT verifica que a especificação continua descrevendo todos os endpoints, o esquema de segurança e os status que o cliente precisa tratar — um contrato gerado que ninguém verifica pode silenciosamente parar de incluir um endpoint.


Autenticação

Todos os endpoints de negócio exigem o header X-API-Key. Sem ele, ou com chave inválida, a resposta é 401 — no mesmo formato de erro do resto da API.

export API_KEY=dev-local-api-key-change-me   # default de desenvolvimento
curl -sH "X-API-Key: $API_KEY" http://localhost:8080/wallets/$WALLET_ID

Chave estática compartilhada, e não JWT/OAuth: o enunciado exclui autenticação de usuário final, então quem chama este serviço é outro serviço. Introduzir token com identidade e ciclo de vida não teria nada para identificar.

A comparação usa MessageDigest.isEqual, não equals — String.equals retorna no primeiro caractere divergente, e o tempo de resposta passaria a vazar quantos caracteres do prefixo estavam certos. Com chave estática e comparação ingênua, isso é um ataque de temporização viável.

Ficam abertos: /actuator/health (um orquestrador precisa checar liveness sem carregar credencial, e a resposta não expõe dado de negócio) e a documentação OpenAPI (o contrato é público por natureza).

⚠️ O default dev-local-api-key-change-me existe para que este README possa ser seguido do zero. A aplicação emite um WARN destacado na subida ao usá-lo e recusa subir com o perfil prod ativo — falhar no deploy é melhor que servir com credencial pública. Em produção, defina WALLET_API_KEY.


Contrato da API

Operação Rota Sucesso
Abrir carteira POST /wallets 201 + Location
Consultar carteira GET /wallets/{id} 200
Extrato GET /wallets/{id}/transactions 200
Registrar operação POST /wallets/{id}/transactions 201, ou 200 na retentativa
Transferir entre carteiras POST /transfers 201, ou 200 na retentativa

Os exemplos abaixo omitem X-API-Key por brevidade; ele é obrigatório em todos.

Abrir carteira

curl -sX POST http://localhost:8080/wallets \
  -H 'Content-Type: application/json' \
  -d '{"holderName": "Maria Souza", "holderDocument": "529.982.247-25"}'
{
  "id": "3f8b1c2e-5a7d-4e91-8b3a-6c2d9f0e1a4b",
  "holderName": "Maria Souza",
  "holderDocument": "52998224725",
  "balance": "0.00",
  "createdAt": "2026-09-03T15:42:10.123456Z",
  "updatedAt": "2026-09-03T15:42:10.123456Z"
}

O documento é normalizado (sem máscara, em caixa alta) e é chave natural de unicidade — repetir um POST /wallets com o mesmo documento responde 409, em vez de criar carteira duplicada.

Validação do documento

holderDocument aceita CPF (11 dígitos) ou CNPJ (14 caracteres), com ou sem máscara. Qualquer outro tamanho é recusado — nem menor, nem maior.

Como é validado
CPF Somente dígitos. Dígito verificador calculado localmente; sequências de dígitos iguais (111.111.111-11) são recusadas mesmo passando no cálculo
CNPJ Alfanumérico: letras aceitas nas doze primeiras posições, dígitos verificadores sempre numéricos. DV local e situação cadastral consultada na BrasilAPI. Só descricao_situacao_cadastral == "ATIVA" abre carteira

CNPJ alfanumérico. K9.4EG.9DD/0001-42 é tão válido quanto 19.131.243/0001-97. O cálculo do DV é o mesmo dos dois: o valor de cada posição é o código ASCII menos 48, o que para 0–9 dá o próprio dígito e para A–Z dá 17 a 42. O CNPJ numérico é só o caso particular em que nenhuma das doze posições usa letra — não há dois caminhos no código, e nenhum documento existente mudou de veredito. Caixa é irrelevante: k94eg9dd000142 e K94EG9DD000142 são o mesmo titular, e a normalização para maiúsculas é o que impede que a diferença fure a unicidade. O CPF não acompanhou: 11 posições seguem somente numéricas.

A ordem importa: o dígito verificador é conferido antes de qualquer consulta. Não faz sentido perguntar à Receita sobre um número que nem chega a ser um CNPJ — e assim a esmagadora maioria dos erros de digitação nunca custa uma ida à rede.

Fail-closed. Se a consulta não responder dentro do orçamento (3 tentativas, backoff exponencial, teto de 6s), a resposta é 503 com Retry-After — não 201. Aceitar sem confirmar criaria carteira que o serviço nunca prometeu aceitar, e não há processo posterior aqui que a encontrasse. CPF não consulta nada: uma queda da BrasilAPI não derruba a abertura de carteira de pessoa física.

Enquanto a BrasilAPI não ler o CNPJ alfanumérico, ela responde 400 a ele — e a regra acima já cobre o caso: vira 503, não 422. A validação estrutural (tamanho, formato, DV) é nossa e aceita o formato novo hoje; a confirmação de situação cadastral depende do fornecedor. Recusar o titular por uma limitação de terceiro seria acusá-lo de um erro que ele não cometeu. Quando o provedor passar a responder, nada muda no código.

Duas métricas isolam a saúde dessa dependência, separadas das recusas de negócio: wallet.document.lookup.failures e wallet.document.lookup.duration. Ver DECISIONS.md.

Dinheiro vai no JSON como string, não como número. Um número JSON convida o cliente a parsear em double e perder centavos; como string, a escala também fica inequívoca ("0.00", nunca 0.0). A entrada aceita as duas formas. Ver DECISIONS.md.

Registrar crédito ou débito

O header Idempotency-Key é obrigatório.

curl -sX POST http://localhost:8080/wallets/$WALLET_ID/transactions \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"type": "CREDIT", "amount": "100.00", "description": "deposito inicial"}'
{
  "id": "9c4e7a11-2b8f-4d63-a05e-7f1b3c8d2e90",
  "walletId": "3f8b1c2e-5a7d-4e91-8b3a-6c2d9f0e1a4b",
  "type": "CREDIT",
  "amount": "100.00",
  "balanceAfter": "100.00",
  "description": "deposito inicial",
  "idempotencyKey": "b1f0...",
  "createdAt": "2026-09-03T15:43:02.987654Z"
}

type aceita CREDIT ou DEBIT. amount deve ser maior que zero e ter no máximo 2 casas decimais — um valor com 3 casas é recusado, não arredondado.

Repetir a chamada com a mesma Idempotency-Key e o mesmo conteúdo devolve 200, corpo idêntico e o header Idempotent-Replay: true. Reusar a chave com conteúdo diferente devolve 409.

Extrato

curl -s "http://localhost:8080/wallets/$WALLET_ID/transactions?page=0&size=20\
&from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z"
{
  "walletId": "3f8b1c2e-...",
  "content": [ { "type": "DEBIT", "amount": "30.00", "balanceAfter": "70.00", "...": "..." } ],
  "page": 0, "size": 20, "totalElements": 2, "totalPages": 1,
  "first": true, "last": true
}
Parâmetro Default Notas
page 0
size 20 Máximo 100
from — ISO-8601, inclusivo
to — ISO-8601, exclusivo

O intervalo é semiaberto: [from, to). Com to inclusivo, quem quisesse "o mês de setembro" precisaria informar 30T23:59:59.999999 e qualquer divergência de precisão descartaria linhas em silêncio. Semiaberto, informa-se o primeiro instante de outubro e nada escapa nem é contado duas vezes ao paginar meses consecutivos.

Ordenação é fixa do servidor: created_at DESC, id DESC. O id como desempate é o que impede a paginação de repetir ou perder linhas quando duas transações compartilham o mesmo instante — situação comum justamente sob concorrência.

Transferir entre carteiras

Débito na origem e crédito no destino em uma única transação: ou as duas pernas acontecem, ou nenhuma acontece.

curl -sX POST http://localhost:8080/transfers \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"sourceWalletId": "3f8b...", "targetWalletId": "7d2c...", "amount": "30.00"}'
{
  "id": "b7e3a901-...",
  "sourceWalletId": "3f8b...", "targetWalletId": "7d2c...",
  "amount": "30.00",
  "sourceBalanceAfter": "70.00",
  "targetBalanceAfter": "30.00",
  "outgoingTransactionId": "...", "incomingTransactionId": "...",
  "createdAt": "2026-09-03T15:50:11.442100Z"
}

A resposta informa o saldo das duas carteiras: quem acabou de mover dinheiro entre elas não deveria precisar de duas consultas extras para saber onde ele parou.

Se a origem não tiver saldo, a transferência inteira é recusada com 422 e o crédito no destino não acontece.

As duas pernas aparecem no extrato das respectivas carteiras como operações normais, ligadas pelo id da transferência — quem consulta o extrato vê o movimento sem precisar conhecer o conceito de transferência, e o extrato continua somável por construção.

A chave de idempotência aqui é única globalmente, não por carteira: uma transferência não pertence a uma única carteira, então não há carteira que possa escopar a chave.

⚠️ Transferências concorrentes em sentidos opostos (A→B e B→A) são um caso clássico de deadlock. Como isso é evitado está em Transferências e deadlock.


Semântica dos status HTTP

O enunciado pede para decidir e defender o uso de 400, 404, 409 e 422. O critério é único: 400 é falha de forma; 422 é falha de conteúdo.

Situação Status code Por quê
Carteira / operação criada 201 — Recurso novo; Location aponta para ele
Retentativa idempotente 200 — Nada foi criado nesta chamada. 201 mentiria sobre criação, e o Location apontaria para algo que já existia
JSON malformado 400 MALFORMED_REQUEST O servidor não conseguiu parsear
UUID inválido na rota 400 MALFORMED_REQUEST Falha de protocolo, não de negócio
Idempotency-Key ausente 400 MALFORMED_REQUEST Sem a chave o serviço não pode prometer gravação repetível; aceitar seria prometer garantia que não cumpre
amount <= 0, campo ausente, > 2 casas decimais 422 VALIDATION_ERROR JSON válido, conteúdo inaceitável. details traz o problema por campo
type desconhecido 422 VALIDATION_ERROR JSON válido; a resposta lista os valores aceitos
Saldo insuficiente 422 INSUFFICIENT_BALANCE Ver defesa abaixo
Carteira inexistente 404 WALLET_NOT_FOUND Também no extrato: 404, não página vazia
Mesma chave, conteúdo diferente 409 IDEMPOTENCY_KEY_CONFLICT Conflito genuíno: a chave já está vinculada a outra operação
Transferência para a própria carteira 422 INVALID_TRANSFER Não move dinheiro; é erro do cliente, não operação válida com efeito nulo
Documento de titular já existe 409 WALLET_ALREADY_EXISTS Conflito reconciliável: buscar a carteira que já existe
Documento com tamanho ≠ 11/14 ou dígito verificador errado 422 VALIDATION_ERROR A message nomeia a causa (CPF invalido: digito verificador nao confere.) e details aponta holderDocument. Erro estrutural, resolvido sem consultar ninguém
Letra fora da posição permitida (5299822472A, K94EG9DD0001A2) 422 VALIDATION_ERROR Causa própria na message: CPF invalido: deve conter apenas digitos. / CNPJ invalido: os dois ultimos caracteres devem ser digitos. — "formato que não existe" é diferente de "documento que não existe"
CNPJ não está ATIVA 422 CNPJ_NOT_ACTIVE A message traz a situação cadastral: BAIXADA é definitivo, SUSPENSA é regularizável
CNPJ inexistente na Receita 422 CNPJ_NOT_FOUND 404 diria "a carteira não existe" — reusá-lo confundiria dois recursos na mesma resposta
Não foi possível consultar o CNPJ 503 DOCUMENT_VALIDATION_UNAVAILABLE Fail-closed, com Retry-After. Ver abaixo
X-API-Key ausente ou inválida 401 UNAUTHORIZED Credencial ausente ou errada é falha de autenticação. 403 significaria "autenticado, porém proibido", que não existe com uma única chave estática
Inesperado 500 INTERNAL_ERROR Sem vazar detalhe interno; o traceId liga ao log

Saldo insuficiente: por que 422 e não 409

409 promete ao cliente "houve conflito de estado; reconcilie e repita". Um débito acima do saldo não se resolve repetindo — é regra de negócio recusando a operação, e repetir produz exatamente a mesma recusa.

422 Unprocessable Content descreve o caso com precisão: a requisição está bem-formada, a carteira existe, e é o estado dela que a torna inaplicável. 409 foi considerado e descartado por prometer ao cliente uma ação (retry) que não o levaria a lugar algum.

Já o conflito de idempotência é 409 de manual: ali existe de fato um recurso em estado conflitante, e o cliente resolve gerando uma chave nova.


Payload de erro

Toda falha — validação, negócio, autenticação ou inesperada — sai neste formato. Um cliente escreve um único tratamento de erro e ele vale para a API inteira.

{
  "code": "INSUFFICIENT_BALANCE",
  "message": "Saldo insuficiente na carteira 3f8b1c2e-...: saldo 100.00, debito solicitado 150.00.",
  "timestamp": "2026-09-03T15:45:12.334Z",
  "path": "/wallets/3f8b1c2e-.../transactions",
  "traceId": "a1b2c3d4e5f60718"
}

details aparece apenas em erro de validação:

{
  "code": "VALIDATION_ERROR",
  "message": "Valor deve ser maior que zero.",
  "details": [ { "field": "amount", "issue": "Valor deve ser maior que zero." } ]
}

Quando um único campo é o culpado — o caso comum — a message é o problema: CPF invalido: digito verificador nao confere., e não um genérico "um ou mais campos são inválidos" que obriga quem integra a abrir details para descobrir o que houve. É por isso que cada constraint declara uma frase completa em vez de um fragmento colado ao nome do campo: a mesma frase serve para o message de topo e para o issue do campo.

Com mais de um campo inválido a message volta ao texto genérico e details é o lugar de ler o conjunto. A Bean Validation não garante ordem entre violações; concatená-las produziria a mesma resposta escrita de formas diferentes a cada chamada.

code é contrato público — clientes podem programar em cima dele. O traceId também vai no header X-Trace-Id e permite recuperar o log da requisição inteira, o que importa justamente sob concorrência, onde várias se intercalam no mesmo arquivo.

Não existe um único try/catch genérico nos controllers ou nos serviços: quem não sabe tratar um erro deixa subir, e um único @RestControllerAdvice faz a tradução.


Modelagem do domínio

Wallet (raiz do agregado)          WalletTransaction
  id            UUID                 id                  UUID
  holder        Holder  (VO)         walletId            UUID  ──┐ referência
  balance       Money   (VO)         type                CREDIT|DEBIT  por id
  createdAt                          amount              Money (VO)
  updatedAt                          balanceAfter        Money (VO)
                                     idempotencyKey      IdempotencyKey (VO)
  open()                             requestFingerprint  RequestFingerprint (VO)
  credit() ──┐  ponto único          description
  debit()  ──┴─ de mudança           createdAt
              de saldo

Wallet é raiz de agregado. Não existe setter de saldo: a única forma de mover dinheiro é credit() ou debit(), e as duas convergem para um único método privado — a validação existe em um lugar só e não pode divergir entre as direções.

Wallet não mantém coleção de transações. Um agregado que carregasse o próprio extrato precisaria ler um histórico ilimitado para aplicar uma única operação — inviável sob a concorrência que o serviço precisa suportar. As transações referenciam a carteira por id (agregados pequenos, referência por identidade).

Uma transação só existe se foi aplicada. Não há estado "pendente" nem "recusada": um débito sem saldo não gera linha alguma. Isso torna o extrato somável por construção — a soma dos créditos menos a dos débitos é sempre igual ao saldo, e é exatamente essa igualdade que os testes de concorrência verificam.

Money é Value Object, e tira BigDecimal de circulação pelo domínio: escala, sinal e arredondamento passam a ser garantia do tipo, não disciplina de quem chama. Nunca negativo (invariante do próprio tipo) e não arredonda — três casas decimais é erro, não motivo para arredondar.

Schema

wallets
  id               UUID PK
  holder_name      VARCHAR(150)  NOT NULL
  holder_document  VARCHAR(14)   NOT NULL  UNIQUE
  balance          NUMERIC(19,2) NOT NULL  CHECK (balance >= 0)   ← invariante no schema
  created_at, updated_at  TIMESTAMPTZ

wallet_transactions
  id                   UUID PK
  wallet_id            UUID NOT NULL → wallets(id)
  type                 VARCHAR(10)   CHECK (type IN ('CREDIT','DEBIT'))
  amount               NUMERIC(19,2) CHECK (amount > 0)
  balance_after        NUMERIC(19,2) CHECK (balance_after >= 0)
  idempotency_key      VARCHAR(80)
  request_fingerprint  VARCHAR(64)
  description          VARCHAR(255)
  created_at           TIMESTAMPTZ

  UNIQUE (wallet_id, idempotency_key)    ← backstop de idempotência
  INDEX  (wallet_id, created_at DESC, id DESC)    ← serve o extrato

balance_after é redundante em teoria e deliberado na prática: é o que torna o extrato auditável linha a linha e permite provar consistência sem reconstruir o histórico inteiro.


Estratégia de concorrência

Três camadas, em profundidade. A primeira resolve; as outras duas existem para que uma falha futura de código não consiga violar a invariante.

1. Bloqueio pessimista de escrita na raiz do agregado

Toda mutação de saldo carrega a carteira com SELECT ... FOR UPDATE, dentro de uma única transação:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select w from Wallet w where w.id = :walletId")
Optional<Wallet> findByIdForUpdate(@Param("walletId") UUID walletId);

As escritas de uma mesma carteira serializam; carteiras distintas seguem integralmente paralelas — o lock é por linha, não por tabela.

O detalhe que faz isso ser correto: sob READ COMMITTED (default do PostgreSQL), o SELECT ... FOR UPDATE — ao contrário de um SELECT comum — relê a versão mais recente já commitada no instante em que o lock é concedido. Por isso a segunda de duas transações concorrentes decide sobre o saldo que a primeira deixou, e não sobre o que ela mesma leu antes de esperar.

Ordem deliberada dentro da transação:

  1. Travar — nenhuma decisão sobre saldo antes disso;
  2. Aplicar pelo agregado — o serviço não toca em campo nenhum;
  3. Persistir com flush explícito — para que uma violação de constraint apareça dentro do método, e não no commit, onde já não haveria como traduzi-la.

O lock é mantido do carregamento ao commit. É deliberado: a janela é curta — uma leitura, uma decisão em memória, dois writes — e comprar essa janela é o que elimina a necessidade de retry.

2. UNIQUE (wallet_id, idempotency_key)

O backstop. Duas requisições gêmeas simultâneas passam ambas pela verificação de leitura da aplicação; quem chega depois ao lock colide aqui, no flush. A violação não é escondida — é o sinal de que uma gêmea ganhou a corrida, e dispara a devolução do resultado dela.

3. CHECK (balance >= 0)

Mesmo que algum caminho futuro burle o agregado, o PostgreSQL recusa. A invariante do enunciado passa a ser garantida pelo schema, não apenas pela aplicação.

Alternativa considerada: lock otimista

@Version com retry é melhor sob baixa contenção. Mas o cenário deste serviço é precisamente uma carteira quente: ali o otimista degenera em tempestade de retries e latência de cauda ruim. O trade-off assumido é trocar throughput em contenção alta por latência previsível e ausência de retry. Detalhado em DECISIONS.md.

A prova de que o lock está fazendo o trabalho

Testes de concorrência que passam podem estar passando porque a concorrência não é real. Para verificar que os deste projeto têm dentes, o findByIdForUpdate foi trocado por um findById comum e a suíte re-executada. 5 dos 10 testes quebraram, com estes números:

"debitos aplicados"  expected: 10    but was: 100
 saldo final         expected: 0.82  but was: -2.94

Ou seja: sem o lock, todos os cem débitos concorrentes passam e o saldo fica negativo. É exatamente a corrida que a estratégia elimina — e o teste a detecta.

Transferências e deadlock

Uma transferência trava duas carteiras, e isso introduz um risco que a operação simples não tem.

Transferências A→B e B→A concorrentes travariam uma à outra se cada uma bloqueasse primeiro a própria origem: a primeira fica com o lock de A esperando B, a segunda com o de B esperando A, e o PostgreSQL aborta uma delas por deadlock. Sob carga, isso aparece como falhas intermitentes e sem padrão aparente — o tipo de defeito que sobrevive à revisão de código e só se manifesta em produção.

A solução é travar em ordem determinística — aqui por UUID crescente — independentemente de quem é origem e quem é destino. Com todas as transações adquirindo os locks na mesma ordem, o ciclo de espera deixa de ser possível.

A mesma verificação foi aplicada aqui: removendo o sort, 2 dos 4 testes de transferência quebram, com 93 e 106 respostas 5xx, deadlock detected no log do PostgreSQL, e a suíte passando de 1,4 s para 56,6 s — o deadlock_timeout disparando repetidamente. Com a ordenação: zero falhas.


Idempotência

A garantia: a mesma operação chegando duas vezes afeta o saldo uma única vez e a segunda chamada devolve o mesmo resultado da primeira; reusar a chave para operação diferente é conflito.

Escopo da chave

Única por carteira, não global. A chave é gerada pelo cliente para uma chamada a POST /wallets/{id}/transactions, e escopá-la ao recurso que ela muta impede que dois clientes distintos, ao sortear a mesma chave, vejam um conflito que não existe.

Como "operação diferente" é detectado

Por uma impressão digital do conteúdo — SHA-256 sobre walletId | type | amount | description. A chave sozinha não distingue "o cliente repetiu a mesma operação" de "o cliente reusou a chave para outra coisa".

A description entra no cálculo: reusar a chave mudando a descrição é registrar outra operação. Aplicar a nova quebraria a garantia de idempotência; devolver a antiga responderia sobre algo que o cliente não pediu. Recusar é a menos pior das três.

Os três caminhos

Caminho O que acontece Resposta
Retentativa comum A leitura antecipada encontra a operação. Devolve o resultado original sem tomar lock e sem escrever 200
Primeira vez Aplica sob lock 201
Corrida perdida A gêmea commitou entre a leitura e o insert. O banco recusa, relemos em transação nova e devolvemos o resultado dela 200

A leitura antecipada é segura fora de transação porque uma linha de wallet_transactions é imutável depois de escrita (todas as colunas são updatable = false): se ela existe, existe commitada e não muda mais.

A armadilha que o desenho evita

TransactionRegistrationService é um bean separado de WalletTransactionApplier, e isso não é organização — é requisito de funcionamento.

Quando a gêmea perdedora viola a constraint, sua transação já está condenada: não há como consultar nada dentro dela, e o rollback é obrigatório (e desejado — descarta a mutação de saldo que ela havia feito). O tratamento tem de ocorrer fora daquela transação, em uma nova. E para isso a chamada precisa atravessar o proxy do Spring.

Uma auto-invocação (this.aplicar(...)) não passa pelo proxy: o @Transactional é ignorado, o tratamento da corrida para de funcionar — e nada disso quebra a compilação, estoura exceção ou falha nos testes sequenciais. O sintoma seria apenas saldo errado sob concorrência.

IdempotencyConcurrencyIT existe para detectar exatamente essa regressão.


Testes

./mvnw test      # unitários: rápidos, sem Spring e sem banco
./mvnw verify    # tudo, incluindo integração e concorrência
Suíte Quantidade O que cobre
Unitários (*Test, surefire) 181 Money (escala, sinal, precisão), Wallet (invariantes, bordas), coordenação da idempotência com Mockito, formato de dinheiro no wire, CPF/CNPJ (dígito verificador, tamanho, máscara, formato alfanumérico) e o cliente da BrasilAPI (retry, fail-closed, timeouts reais)
Integração (*IT, failsafe) 125 Jornada completa, cada endpoint em profundidade, idempotência sequencial, extrato e paginação, transferências, autenticação, OpenAPI, observabilidade, schema/mapeamento, validação de documento ponta a ponta (CPF, CNPJ numérico e alfanumérico), e 14 testes de concorrência real

Os testes que são a prova da entrega

BalanceConsistencyConcurrencyIT — saldo 100.00, cem débitos simultâneos de 10.00. Não há aleatoriedade no resultado esperado: exatamente 10 aplicados, 90 recusados, saldo 0.00, 11 linhas no extrato. Mais: carga mista de 150 operações concorrentes verificando que soma(créditos) − soma(débitos) == saldo e que nenhum balance_after ficou negativo.

IdempotencyConcurrencyIT — 50 requisições gêmeas simultâneas: exatamente 1 criada, 49 replays, todas apontando para a mesma transação, saldo movido uma vez. Com conteúdos diferentes: 1 aplicada e 49 conflitos, nunca o resultado alheio.

TransferConcurrencyIT — transferências em sentidos opostos, sem deadlock; ciclo A→B→C→A sob carga com o total conservado; e cem transferências concorrentes sobre saldo para dez, das quais exatamente 10 são aplicadas.

E a prova sob carga real

Além dos testes, o teste de carga k6 exercitou 50.709 transações concorrentes sobre uma única carteira e reconciliou o extrato inteiro contra o saldo:

soma dos creditos ........ 1072083.06
soma dos debitos ......... 55262.91
saldo esperado ........... 1016820.15
saldo final na carteira .. 1016820.15
divergencia .............. 0.00

~1.098 req/s sustentados, p(95) de 46 ms, zero respostas 5xx em 63.803 requisições, 9.413 retentativas atendidas por replay e 3.171 débitos corretamente recusados por saldo.

Duas decisões de teste que importam

Nenhum teste de integração é @Transactional. Um teste transacional enxergaria as próprias escritas não commitadas e desfaria tudo no final — cômodo, mas mentiroso: esconderia justamente o comportamento de commit e de lock que o serviço precisa provar. Cada teste limpa o estado com TRUNCATE antes de rodar.

Isso é ainda mais crítico na base de concorrência, que por isso não herda da base de integração: se a classe fosse transacional, tudo rodaria em uma única transação, as escritas não seriam visíveis entre threads e a corrida sob teste simplesmente não aconteceria. O teste passaria sempre — inclusive com o código errado.

Servidor HTTP real, não MockMvc, nos testes de concorrência. MockMvc executa na thread do teste; não existiriam requisições concorrentes para disputar nada. As tarefas partem de uma largada comum (CountDownLatch) para chegarem juntas, em vez de se enfileirarem e tornarem a contenção acidental.

Todas as verificações finais consultam o banco, não a API. Se as duas discordarem, é o banco que tem razão — e é ele que guarda o dinheiro.


Observabilidade

Health (com indicador db) /actuator/health — aberto, sem chave
Liveness / readiness /actuator/health/liveness, /actuator/health/readiness
Métricas /actuator/metrics, /actuator/prometheus

liveness e readiness são separados porque reiniciar um pod que apenas perdeu o banco não resolve nada — ele precisa sair do balanceador, não morrer. O indicador db importa pelo mesmo motivo: sem ele, o serviço responderia UP com o PostgreSQL fora e seguiria recebendo tráfego.

A exposição é por lista explícita, não *: expor tudo publicaria env, beans e mappings, revelando configuração interna sem ganho operacional.

Métricas de negócio

Não são métricas genéricas de HTTP — o Actuator já fornece essas. Estas contam a história que este serviço precisa contar em produção:

Métrica O que diz quando sobe
wallet.idempotency.replays Cliente com timeout mal calibrado, retentando o que já deu certo
wallet.idempotency.conflicts Bug no cliente: ele reusa chave para operações diferentes, e alguma operação dele não está acontecendo
wallet.idempotency.races Concorrência real sobre a mesma chave — diferente de retry após timeout
wallet.operations.rejected{code} Separa recusa de negócio esperada de erro técnico
wallet.lock.duration Alerta antecipado de contenção: se a cauda cresce, a fila na carteira quente está se formando antes de virar timeout do cliente

A última é a métrica que a escolha por lock pessimista torna necessária.

O log sai em JSON no profile json (ativo no container) e em texto legível fora dele. O traceId aparece nos dois, e também no header X-Trace-Id e no payload de erro — é ele que liga uma resposta às linhas que a produziram, o que importa justamente sob concorrência, com requisições intercaladas no mesmo arquivo.


Rodando em container

A aplicação também tem imagem própria:

docker compose --profile app up -d --build     # banco + aplicação
docker compose --profile app --profile loadtest run --rm k6   # teste de carga

Imagem multi-stage: build a partir do fonte (reprodutível do repositório), runtime em JRE e não JDK, usuário sem privilégios, camadas separadas para dependências e aplicação, e HEALTHCHECK apontando para readiness — o que interessa ao orquestrador é "pode receber tráfego", o que inclui a conexão com o banco.

O depends_on usa condition: service_healthy, não service_started: com ddl-auto=validate e Flyway na subida, a aplicação morre se o banco ainda não aceita conexão, e o crash-loop resultante parece bug da aplicação.


Estrutura do projeto

src/main/java/br/com/wallet/
  domain/         Wallet, WalletTransaction, Transfer, Money, Holder,
                  IdempotencyKey, RequestFingerprint, TransactionType, exception/
  persistence/    repositórios Spring Data, Specifications, AttributeConverters
  application/    casos de uso, cada um interface + Impl: WalletService,
                  TransactionRegistrationService, WalletTransactionApplier,
                  TransferService, TransferApplier, StatementService,
                  WalletMetrics (porta), CnpjStatusProvider (porta), command/
  api/            *Api (contrato HTTP) + *Controller (implementação), dto/,
                  error/ (ApiError + ApiExceptionHandler), IdempotencyHttpHeaders
  config/         SecurityConfig, ApiKeyAuthFilter, OpenApiConfig,
                  JacksonConfig, TraceIdFilter, MicrometerWalletMetrics
  integration/    BrasilApiCnpjClient e config do cliente HTTP (adaptador de CnpjStatusProvider)

src/main/resources/db/migration/    V1‑V4 — schema versionado
docker/postgres/init/               cria o banco de testes
loadtest/k6/                        teste de carga + prova de consistência
docs/openapi.json                   especificação versionada

Separação de camadas sem exagero: o enunciado pede as duas coisas ao mesmo tempo, "sem camadas excessivas" incluído. Não há port/adapter escrito à mão sobre o Spring Data, e as entidades de domínio são também entidades JPA — trade-off assumido e justificado em DECISIONS.md. Já os colaboradores de application, a fachada de métricas e os três controllers têm cada um uma interface e uma implementação (*Impl nos services, *Api/nome-do-controller no HTTP) — razão e convenção de nomes em DECISIONS.md.

Controllers apenas traduzem HTTP ↔ comando/DTO. Zero regra de negócio.


Decisões técnicas

O raciocínio completo — o que foi escolhido, por quê, quais alternativas foram descartadas e o que seria feito diferente com mais tempo — está em DECISIONS.md.

About

Backend de carteira digital em **Java 17+ / Spring Boot**, com crédito, débito, extrato e idempotência, projetado para receber tráfego concorrente sobre a mesma carteira.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages