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:
- O saldo nunca fica negativo.
- 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.
- Como rodar do zero
- Stack
- Documentação da API
- Autenticação
- Contrato da API
- Semântica dos status HTTP
- Payload de erro
- Modelagem do domínio
- Estratégia de concorrência
- Idempotência
- Testes
- Observabilidade
- Rodando em container
- Estrutura do projeto
- Decisões técnicas
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:runA 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, responde503. Em ambiente sem egress para a internet, oPOST /walletscom 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.
| 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 |
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.
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_IDChave 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 defaultdev-local-api-key-change-meexiste 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 perfilprodativo — falhar no deploy é melhor que servir com credencial pública. Em produção, definaWALLET_API_KEY.
| 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.
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.
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
400a ele — e a regra acima já cobre o caso: vira503, não422. 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
doublee perder centavos; como string, a escala também fica inequívoca ("0.00", nunca0.0). A entrada aceita as duas formas. Ver DECISIONS.md.
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.
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.
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→BeB→A) são um caso clássico de deadlock. Como isso é evitado está em Transferências e deadlock.
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 |
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.
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.
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.
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 extratobalance_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.
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.
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:
- Travar — nenhuma decisão sobre saldo antes disso;
- Aplicar pelo agregado — o serviço não toca em campo nenhum;
- 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.
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.
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.
@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.
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.
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.
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.
Ú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.
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.
| 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.
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.
./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 |
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.
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.
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.
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.
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.
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 cargaImagem 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.
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.
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.