Validar CPF em Java Spring Boot: algoritmo mod-11 e JUnit
Validar CPF em Java Spring Boot: algoritmo mod-11 e JUnit
A Receita Federal define o CPF como uma sequência de 11 dígitos onde os dois últimos são verificadores calculados por mod-11. Qualquer validação que não reimplemente esse cálculo falha em produção quando recebe CPFs mascarados, strings Unicode ou sequências repetidas. Este tutorial cobre o algoritmo completo, a integração com Bean Validation no Spring Boot e a cobertura de testes com JUnit 5.
O algoritmo mod-11 do CPF: como a Receita Federal calcula os dígitos verificadores
Estrutura de um CPF: 9 dígitos base + 2 verificadores
Um CPF tem formato NNN.NNN.NNN-DD, onde os 9 primeiros dígitos são o número base e DD são os dois dígitos verificadores. A validação acontece em dois passos independentes, cada um gerando um verificador.
Primeiro dígito: pesos de 10 a 2
Multiplique cada um dos 9 dígitos base pelos pesos de 10 a 2 (em ordem). Some os produtos. Calcule resto = soma % 11. Se resto < 2, o primeiro verificador é 0. Caso contrário, é 11 - resto.
Segundo dígito: pesos de 11 a 2 (inclui o primeiro verificador)
Repita o processo usando os 9 dígitos base mais o primeiro verificador já calculado, com pesos de 11 a 2. O segundo verificador segue a mesma regra do resto.
Regra do resto menor que 2 (resultado = 0)
Essa regra existe porque a Receita Federal optou por representar restos 0 e 1 como o dígito 0, evitando verificadores negativos ou em dois dígitos. É a fonte mais comum de bugs em implementações de terceiros.
CPFs com todos os dígitos iguais: inválidos por definição, não pelo mod-11
111.111.111-11 passa no algoritmo matemático. A Receita Federal os rejeita por definição administrativa. Seu código precisa checar isso explicitamente antes de rodar o mod-11.
Implementando o validador CPF puro em Java (sem frameworks)
Método isValidCpf(String cpf): limpeza, tamanho, dígitos iguais, dois loops
public final class CpfValidator {
private CpfValidator() {}
public static boolean isValidCpf(String cpf) {
if (cpf == null) return false;
String digits = cpf.replaceAll("[^0-9]", "");
if (digits.length() != 11) return false;
// Rejeita sequências uniformes (000...000, 111...111, etc.)
if (digits.chars().distinct().count() == 1) return false;
return checkDigit(digits, 10) && checkDigit(digits, 11);
}
private static boolean checkDigit(String digits, int weight) {
int sum = 0;
int limit = weight - 1; // 9 ou 10 dígitos a processar
for (int i = 0; i < limit; i++) {
sum += Character.getNumericValue(digits.charAt(i)) * (weight - i);
}
int remainder = sum % 11;
int expected = (remainder < 2) ? 0 : 11 - remainder;
return expected == Character.getNumericValue(digits.charAt(limit));
}
}Tratando entradas com máscara (000.000.000-00) e sem máscara
O replaceAll("[^0-9]", "") remove pontos, traços e qualquer outro caractere não numérico antes de qualquer cálculo. Isso cobre CPFs mascarados e não mascarados sem lógica extra.
Por que não usar Long.parseLong para CPF (zero à esquerda)
CPFs iniciados em zero, como 001.234.567-89, perdem o zero se convertidos para long. Trabalhe sempre com String e acesse cada caractere individualmente via charAt ou getNumericValue.
Cobertura de testes com JUnit 5 antes de integrar ao Spring
Partições de equivalência: válidos, inválidos, nulos, vazios, mascarados
Antes de qualquer integração, o algoritmo puro precisa passar em todas as partições de equivalência. Não há valor em subir o Spring para testar uma função que só recebe String.
CPFs "famosos" de teste
444.444.444-44 é inválido (sequência uniforme). Para testes, use CPFs gerados deterministicamente. O gerador de CPF do FakeForge gera CPFs válidos que você pode usar como fixtures confiáveis.
Anotação @ParameterizedTest com @CsvSource para 12 casos em 8 linhas
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;
class CpfValidatorTest {
@ParameterizedTest
@CsvSource({
"529.982.247-25, true", // CPF válido com máscara
"52998224725, true", // CPF válido sem máscara
"111.111.111-11, false", // sequência uniforme
"000.000.000-00, false", // todos zeros
"123.456.789-09, false", // dígito verificador errado
"529.982.247-26, false", // último dígito errado por 1
", false", // null (CSV trata como null)
"'', false", // string vazia
"529.982.247, false", // 10 dígitos apenas
"5299822472599, false", // 13 dígitos
"529.982.247-2A, false", // letra no verificador
"529 982 247 25, true" // espaços como separador
})
void validaCpf(String cpf, boolean expected) {
assertEquals(expected, CpfValidator.isValidCpf(cpf));
}
}Gerador de CPF de teste: /gerador-cpf como fonte de fixtures confiáveis
Para fixtures em quantidade, use a API REST do FakeForge:
curl "https://fakeforge.com.br/api/generate?type=cpf&quantity=20&format=json"Os CPFs retornados passam no algoritmo mod-11. Você pode validar que o seu próprio isValidCpf concorda com a geração, criando um teste de sanidade cruzado.
Criando a anotação @CPF personalizada com Bean Validation
Dependência jakarta.validation-api no pom.xml
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.0.2</version>
</dependency>
<!-- Hibernate Validator como implementação (já incluído via Spring Boot Starter Web) -->Interface @CPF e classe CpfValidator
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = CpfConstraintValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CPF {
String message() default "{br.com.suaapp.validation.cpf}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class CpfConstraintValidator implements ConstraintValidator<CPF, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null || value.isBlank()) return false;
return CpfValidator.isValidCpf(value);
}
}Retornando mensagens de erro localizadas via messages.properties
Crie src/main/resources/ValidationMessages.properties:
br.com.suaapp.validation.cpf=CPF invalido. Informe um CPF com 11 digitos validos.DICA: Nomeie a chave com o pacote completo da aplicação para evitar colisão com outras dependências que usem o mesmo arquivo de mensagens.
Integrando a anotação ao Spring Boot: DTO, Controller e tratamento de erros
DTO, Controller e @ControllerAdvice
// DTO
public record CadastroRequest(
@CPF
@NotBlank
String cpf,
@NotBlank
String nome
) {}
// Controller
@RestController
@RequestMapping("/api/cadastro")
public class CadastroController {
@PostMapping
public ResponseEntity<Void> cadastrar(@Valid @RequestBody CadastroRequest req) {
// processamento
return ResponseEntity.ok().build();
}
}
// ControllerAdvice
@RestControllerAdvice
public class ValidationAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public Map<String, Object> handleValidation(MethodArgumentNotValidException ex) {
List<Map<String, String>> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> Map.of(
"campo", fe.getField(),
"mensagem", fe.getDefaultMessage()
))
.toList();
return Map.of(
"status", 422,
"erros", errors
);
}
}O payload de erro retorna HTTP 422 com campo e mensagem, compatível com a maioria dos clientes frontend sem acoplamento ao formato de exceção do Spring.
Testando a integração com MockMvc
@WebMvcTest vs @SpringBootTest: qual usar para validação
@WebMvcTest carrega apenas a camada web (controllers, filters, advice). Para testar se a anotação @CPF dispara a mensagem certa e o status correto, @WebMvcTest é suficiente e mais rápido que @SpringBootTest.
@WebMvcTest(CadastroController.class)
class CadastroControllerTest {
@Autowired
MockMvc mvc;
@Autowired
ObjectMapper mapper;
@Test
void cpfInvalido_retorna422() throws Exception {
var body = Map.of("cpf", "111.111.111-11", "nome", "Teste");
mvc.perform(post("/api/cadastro")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(body)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.erros[0].campo").value("cpf"));
}
@Test
void cpfValido_retorna200() throws Exception {
var body = Map.of("cpf", "529.982.247-25", "nome", "Joao");
mvc.perform(post("/api/cadastro")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(body)))
.andExpect(status().isOk());
}
@Test
void cpfComSqlInjection_retorna422SemException() throws Exception {
var body = Map.of("cpf", "'; DROP TABLE usuarios; --", "nome", "Hack");
mvc.perform(post("/api/cadastro")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(body)))
.andExpect(status().isUnprocessableEntity());
}
}O teste de SQL injection confirma que strings maliciosas não geram exceção inesperada: o replaceAll("[^0-9]", "") reduz a string a dígitos antes de qualquer cálculo.
Edge cases que quebram implementações ingênuas
| Entrada | Comportamento esperado | Motivo |
|---|---|---|
000.000.000-00 | inválido | Todos zeros, regra administrativa |
111.111.111-11 até 999.999.999-99 | inválido | Sequências uniformes |
529.982.247-25 | válido | Passa nos dois verificadores |
\u0030\u0031\u0032... | depende | Character.isDigit aceita dígitos Unicode |
529 982 247 25 | válido ou rejeitar | Decisão de negócio, documentar antes do deploy |
529.982.247-2A | inválido | Letra no campo numérico |
AVISO:Character.isDigitretornatruepara dígitos de outros scripts Unicode (árabe-indic, devanágari). OreplaceAll("[^0-9]", "")usa apenas ASCII0-9, então é seguro. Não substitua porCharacter.isDigitsem adicionar um filtro de range.
Performance: quando a validação vira gargalo
O algoritmo é O(1) por CPF
Dois loops de tamanho fixo (9 e 10 iterações). O gargalo em endpoints de lote não é a matemática: é o parsing de JSON e a alocação de strings.
Cache com ConcurrentHashMap para CPFs repetidos em lote
Em cadastros de lote onde o mesmo CPF aparece várias vezes:
private static final Map<String, Boolean> CACHE =
new ConcurrentHashMap<>(1024);
public static boolean isValidCpfCached(String cpf) {
return CACHE.computeIfAbsent(cpf, CpfValidator::isValidCpf);
}Limite o tamanho do cache com Caffeine ou Guava em produção para evitar crescimento ilimitado.
Benchmark com JMH
Em hardware moderno, o algoritmo puro sem cache atinge facilmente 2 milhões de validações por segundo em single-thread. O custo real em produção é o overhead de serialização e rede, não o mod-11.
Validação server-side não substitui dados de teste válidos
Por que usar CPFs reais em testes é risco LGPD
A LGPD Art. 46 (Lei 13.709/2018) exige que dados pessoais sejam protegidos por medidas técnicas e administrativas. CPFs reais em repositórios, logs de teste ou bancos de desenvolvimento violam esse princípio mesmo que o titular tenha consentido para outra finalidade (LGPD Art. 7º, III).
Gerando CPFs válidos deterministicamente no setup do JUnit com seed fixo
Para suítes determinísticas sem dependência de rede, implemente um gerador com seed fixo:
@BeforeAll
static void geraFixtures() {
// Gerador determinístico com seed fixo para reprodutibilidade
Random rng = new Random(42L);
fixtures = IntStream.range(0, 50)
.mapToObj(i -> gerarCpfAleatorio(rng))
.filter(CpfValidator::isValidCpf)
.toList();
}/api/generate?type=cpf&quantity=50&format=json como fonte de fixtures
Para fixtures em CI/CD sem lógica de geração no código de teste:
curl "https://fakeforge.com.br/api/generate?type=cpf&quantity=50&format=json" \
-H "x-api-key: SUA_CHAVE"O plano Dev (R$29/mês) cobre 10.000 chamadas diárias, suficiente para pipelines de CI com múltiplos jobs paralelos.
Validando que os fixtures do FakeForge passam no seu próprio isValidCpf
@Test
void fixturesFakeForgePassamNaValidacaoLocal() {
fixtures.forEach(cpf ->
assertTrue(CpfValidator.isValidCpf(cpf),
"CPF do FakeForge falhou na validacao local: " + cpf)
);
}Esse teste cria um contrato bidirecional: se a API do FakeForge e o seu algoritmo discordarem, o teste falha imediatamente.
CNPJ segue a mesma lógica: reutilizando a estrutura
Diferenças: 14 dígitos, pesos diferentes, dois loops distintos
O CNPJ usa pesos [5,4,3,2,9,8,7,6,5,4,3,2] no primeiro loop e [6,5,4,3,2,9,8,7,6,5,4,3,2] no segundo. A estrutura do método checkDigit é reutilizável: basta parametrizar os pesos como array.
CNPJ Alfanumérico a partir de 01/07/2026
A Receita Federal publicou em 2024 a extensão do CNPJ para aceitar letras nas posições 1 a 12. O algoritmo mod-11 permanece, mas Character.getNumericValue já suporta letras A-Z como valores 10-35. Use o gerador de CNPJ alfanumérico para fixtures do novo formato antes de ajustar seu validador.
/gerador-cnpj para fixtures do formato atual
O gerador de CNPJ gera CNPJs no formato atual (14 dígitos numéricos) com verificadores corretos, prontos para uso em testes de cadastro de pessoa jurídica.
Resumo
- Implemente
isValidCpfcomreplaceAll("[^0-9]", ""), cheque sequências uniformes antes do mod-11 e use dois loops com pesos fixos. Não useLong.parseLong. - Cubra as 12 partições de equivalência com
@ParameterizedTeste@CsvSourceantes de subir o Spring. - Crie a anotação
@CPFcomConstraintValidatore configure mensagens emValidationMessages.properties. O@ControllerAdvicedeve retornar HTTP 422 comcampoemensagem. - Use
@WebMvcTestpara testar a camada de validação. Inclua o caso de SQL injection para confirmar que o endpoint retorna 422 sem estourar exceção. - Nunca use CPFs reais em testes. Use o gerador de CPF ou a API REST para fixtures válidos e LGPD-safe.
- O próximo passo natural é o validador de CNPJ com os mesmos pesos parametrizados e, a partir de julho de 2026, suporte ao CNPJ alfanumérico.
Perguntas frequentes
Devo validar CPF no frontend (JavaScript) ou apenas no backend?+
Frontend acelera UX com feedback imediato, bom para experiência. Implemente o mod-11 em JavaScript para isso. Porém, nunca confie na validação do cliente. Backend sempre revalida. Backend é a fonte da verdade. Qualquer request direto à API bypassa frontend, então validação no cliente é inútil para segurança. Use frontend para UX, backend para segurança.
Como logar CPFs rejeitados sem violar LGPD?+
Nunca logue o CPF na íntegra em logs. Logue apenas hash SHA-256 ou últimos 2 dígitos. Exemplo: `cpf_last_2: "25", cpf_hash: "abc123...def"`. Assim rastreia rejeições sem expor PII em logs, stack traces ou agregadores. Importante: destrua os hashes após 30 dias (retenção mínima para compliance).
Preciso cachear validações de CPF em lote com 10K itens?+
Sem cache, 10K CPFs em ~5ms (2M ops/seg). Gargalo real é I/O de banco. Cache (Caffeine) ajuda se o mesmo CPF aparece 10+ vezes no lote. Comece sem cache, meça latência com `System.nanoTime()`. Se > 50ms, adicione cache com TTL de 5 min. Benchmark real antes de otimizar é a regra.
Posso reusar a lógica de mod-11 para validar RG ou CNH?+
RG não tem algoritmo Federal padrão (varia por estado). CNH usa mod-11 como CPF, mas com 12 dígitos e pesos distintos (DENATRAN define). Parametrize `checkDigit()` para aceitar array de pesos. Documente qual documento usa qual sequência. Use enums: `enum DocType { CPF(9), CNH(12), ...}`. Assim escala sem duplicação.
Como gerar CPFs válidos para testes sem depender de API externa?+
Implemente gerador com seed fixo em `@BeforeAll`: `new Random(42L)` gera mesma sequência toda execução, determinística. Ou chame `/api/generate?type=cpf&quantity=50` antes do suite rodar. Cache a resposta em arquivo `fixtures.json`. Terceira opção: use [/gerador-cpf](/gerador-cpf) para 50 CPFs manualmente, commit no repo (LGPD-safe porque são sintéticos).