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.
Top comments (0)