DEV Community

CPFHub
CPFHub

Posted on

Validação de CPF em Java com Spring Boot: anotação customizada e cliente HTTP resiliente

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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)