Transparência: sou fundador da CPFHub, citada no final como uma opção de API. O resto do artigo vale para qualquer provedor.
Em Spring Boot, o lugar natural para validar CPF é o Bean Validation (Jakarta Validation). Assim a regra fica declarada no DTO, aparece no erro 400 padronizado e não se espalha por controllers. Vamos fazer isso e depois plugar uma consulta externa com timeout e tratamento de erros.
1. O algoritmo
public final class CpfUtils { private CpfUtils() {} public static String normalizar(String cpf) { return cpf == null ? "" : cpf.replaceAll("\\D", ""); } public static boolean valido(String entrada) { String cpf = normalizar(entrada); if (cpf.length() != 11 || cpf.chars().distinct().count() == 1) return false; for (int t = 9; t < 11; t++) { int soma = 0; for (int i = 0; i < t; i++) { soma += (cpf.charAt(i) - '0') * ((t + 1) - i); } int dv = (soma * 10) % 11 % 10; if (cpf.charAt(t) - '0' != dv) return false; } return true; } } cpf.chars().distinct().count() == 1 elimina 00000000000, 11111111111 etc., que passam no cálculo mas não existem.
2. A anotação @Cpf
@Documented @Constraint(validatedBy = CpfValidator.class) @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface Cpf { String message() default "CPF inválido"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } public class CpfValidator implements ConstraintValidator<Cpf, String> { @Override public boolean isValid(String value, ConstraintValidatorContext ctx) { if (value == null || value.isBlank()) return true; // deixe @NotBlank cuidar disso return CpfUtils.valido(value); } } Repare que nulo retorna true. É a convenção do Bean Validation: cada anotação valida uma coisa só, e obrigatoriedade é papel do @NotBlank.
public record CadastroRequest( @NotBlank String nome, @NotBlank @Cpf String cpf, @Email String email ) {} @PostMapping("/clientes") public ResponseEntity<?> criar(@Valid @RequestBody CadastroRequest req) { ... } Com @Valid, um CPF inválido gera MethodArgumentNotValidException e o Spring devolve 400 sem você escrever um if.
3. Normalize antes de persistir
Use um setter, um @PrePersist ou o próprio service para gravar só dígitos. E crie índice único na coluna. Se o banco tiver 529.982.247-25 e 52998224725, você tem duplicidade invisível.
4. Consulta externa com RestClient
O DV prova formato, não existência. Se o seu fluxo precisa confirmar que o CPF existe e pegar nome e data de nascimento, chame uma API só depois que o DV passar.
No Spring Boot 3.2+ o RestClient é síncrono e simples:
@Component public class CpfClient { private final RestClient http; public CpfClient(@Value("${cpfhub.api-key}") String apiKey) { var factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3_000); factory.setReadTimeout(10_000); this.http = RestClient.builder() .baseUrl("https://api.cpfhub.io") .defaultHeader("x-api-key", apiKey) .requestFactory(factory) .build(); } public Optional<Map<String, Object>> consultar(String cpf) { String limpo = CpfUtils.normalizar(cpf); if (!CpfUtils.valido(limpo)) throw new IllegalArgumentException("CPF inválido"); try { Map<?, ?> body = http.get().uri("/cpf/{cpf}", limpo) .retrieve().body(Map.class); return Optional.ofNullable((Map<String, Object>) body.get("data")); } catch (HttpClientErrorException.NotFound e) { return Optional.empty(); // não encontrado } catch (HttpClientErrorException.TooManyRequests e) { String retry = e.getResponseHeaders().getFirst("Retry-After"); throw new LimiteException(retry); // trate no caller } } } Boas práticas que valem para qualquer provedor:
- Timeout explícito. O default do
SimpleClientHttpRequestFactoryé infinito. - 404 não é exceção de negócio, é resposta. Devolva
Optional.empty(). - 429 deve respeitar
Retry-After. Não faça retry imediato em loop. - 401/403 não devem ser repetidos: é chave errada ou falta de crédito.
5. Resiliência com Resilience4j
Se a consulta estiver no caminho crítico do cadastro, proteja com circuit breaker e um fallback que não bloqueia o usuário: grava o cadastro como "pendente de verificação" e reprocessa depois.
@CircuitBreaker(name = "cpf", fallbackMethod = "pendente") public Resultado verificar(String cpf) { ... } private Resultado pendente(String cpf, Throwable t) { return Resultado.PENDENTE; } Assim, se o provedor cair, sua conversão não cai junto.
6. Testes
Teste o validador com casos de borda:
@ParameterizedTest @CsvSource({"529.982.247-25,true", "52998224725,true", "11111111111,false", "52998224724,false", "123,false"}) void valida(String cpf, boolean esperado) { assertEquals(esperado, CpfUtils.valido(cpf)); } Para o cliente HTTP, use MockRestServiceServer ou WireMock e simule 200, 404 e 429. Nunca rode testes automatizados contra a API real com CPFs de pessoas.
Sobre a CPFHub
Se quiser testar o cliente acima, a CPFHub expõe GET https://api.cpfhub.io/cpf/{cpf} com header x-api-key, retornando nome, gênero e data de nascimento em ~150 ms (típico, não SLA). CPF não encontrado não consome crédito, e o plano gratuito tem 50 créditos por mês. Endpoints e códigos de erro estão na documentação.
Resumo
@Cpf no DTO, normalização antes de persistir, índice único, cliente HTTP com timeout e tratamento de 404/429, circuit breaker com fallback "pendente". Com isso o CPF deixa de ser uma fonte de dados sujos no seu sistema.

