Um sistema de votação parece simples até você sentar para pensar nos invariantes reais. Análise de arquitetura do Diamante do Ano: autenticação, anti-fraude, unicidade de voto e por que cada escolha técnica importa.

Eu moro em Diamantina. Todo ano o Diamante do Ano abre votação para eleger as melhores empresas da cidade por segmento, melhor farmácia, melhor sistema de gestão comercial, melhor escritório de advocacia. A comunidade vota, as empresas exibem o diploma, e o ciclo se repete.
Esse ano usei o sistema como eleitor. E também como engenheiro de software.
Esse post não é sobre o que está errado, é sobre o que eu construiria. O que eu escolheria, o que eu descartaria, e especialmente por quê. Sistemas de votação são um dos melhores exercícios de arquitetura que existem: parecem simples, têm invariantes brutalmente difíceis de garantir, e falham de formas que vão direto para a manchete.
O modelo de ameaça real: não é o hacker, é o WhatsApp
Antes de qualquer decisão técnica, você precisa saber contra quem está jogando.
Sistemas de votação pequenos, como o Diamante do Ano, não atraem hackers profissionais com ferramentas sofisticadas. O adversário real é muito mais prosaico: é o dono de uma empresa que quer ganhar e pede para cinquenta amigos votarem. É o funcionário que distribui o link no grupo da família. São vinte pessoas votando pelo celular do mesmo escritório, no mesmo horário, pelo mesmo roteador.
Esse modelo de ameaça muda radicalmente o que você precisa construir.
Um firewall de rede não resolve. Rate limiting agressivo por IP bloqueia o eleitorado legítimo antes de barrar o fraudador, porque o escritório inteiro sai pelo mesmo IP público. Um sistema projetado contra hackers vai punir a dona de casa de Diamantina que só quer votar na farmácia onde ela compra remédio há dez anos.
A pergunta certa não é "como eu bloqueio ataques sofisticados?" A pergunta certa é "como eu torno matematicamente impossível votar mais de uma vez por segmento, independente de qualquer comportamento da aplicação?"
Esse reframing muda toda a arquitetura.
Impacto em números
Vazamento DETRAN/Serasa 2021, praticamente todos os adultos do país
Twilio / Zenvia, inviabiliza fraude em massa, custo total aceitável
Padrão universal em sistemas com prazo, o pico que ninguém testa
Opera Browser tem VPN grátis nativa, IP blocking é contornável por qualquer pessoa
CPF não é segredo, e 220 milhões de brasileiros provam isso
O sistema atual autentica por CPF. A lógica é intuitiva: CPF é único por pessoa, então um CPF vota uma vez. O problema está numa confusão conceitual entre dois tipos de dado completamente diferentes: identificador e segredo.
Um identificador prova quem você é. Um segredo prova que você tem acesso. CPF é um identificador, ele está no seu RG, na sua carteirinha do plano de saúde, em consultas públicas na Receita Federal. Em 2021, um vazamento expôs os dados de 220 milhões de brasileiros, incluindo CPF de pessoas mortas. Seu CPF provavelmente está em pelo menos uma base de dados ilegítima agora mesmo.
Usar CPF como autenticação em um sistema de votação é tecnicamente equivalente a usar o nome da pessoa como senha. Qualquer um que conheça o número vota no lugar do eleitor legítimo. A superfície de fraude é a cidade inteira.
O que muda com um segundo fator é simples: CPF deixa de ser a prova e vira o identificador. A prova real é o OTP enviado para o número de celular associado ao CPF via consulta na Receita Federal. Você não autentica com algo que você sabe, você autentica com algo que você tem. Um fraudador com o CPF de outra pessoa não consegue completar o flow sem acesso ao celular dela.
Esse princípio, separar identificação de autenticação, está na base de qualquer sistema que lida com algo que importa. Bancos fazem isso. O TSE faz isso. Sistemas de votação locais deveriam fazer também.
A objeção comum é "adiciona fricção". Adiciona. Mas fricção intencional em sistemas de votação é uma propriedade desejável, não um defeito. O custo de votar um pouco mais devagar é incomparavelmente menor que o custo de ter o resultado do evento contestado publicamente.
IP blocking é teatro de segurança
Existe um termo em segurança da informação chamado security theater: medidas que parecem proteger, criam a sensação de proteção, mas não protegem de nada contra o adversário real.
IP blocking num sistema de votação municipal é um caso claro.
O mecanismo tem dois modos de falha opostos que se anulam:
O primeiro é o falso positivo. Toda empresa em Diamantina tem todos os funcionários saindo pelo mesmo IP público. Um escritório com dez pessoas, cada uma com seu CPF, cada uma votando legitimamente, pode ter os últimos oito bloqueados por uma regra de IP que nunca teve intenção de bloquear eleitor legítimo.
O segundo é o falso negativo. O Opera Browser tem VPN gratuita embutida. O NordVPN custa R$15 por mês. Mudar de IP é uma questão de trinta segundos para qualquer pessoa com motivação mínima. Um fraudador votando por cinquenta CPFs de família não vai ser detido por IP blocking, vai ser atrasado por menos de um minuto.
Duas defesas fracas não fazem uma forte. Defesa em camadas funciona quando cada camada cobre os pontos cegos das outras. IP blocking não cobre os pontos cegos do CPF, cobre os casos mais fáceis e deixa os casos que importam passarem.
O que eu usaria IP para fazer: sinal de risco, não portão de entrada. Múltiplos CPFs diferentes vindos do mesmo IP em pouco tempo? Sobe o score de suspeita e exige re-autenticação. O eleitor legítimo do escritório passa sem perceber. O script que está votando por cem CPFs diferentes vai encontrar atrito crescente até travar. Você distingue os dois porque o padrão é diferente, não porque o IP é o mesmo.
A única garantia real mora no banco de dados
Aqui está o ponto que eu vejo mal implementado em praticamente todo sistema de votação online que já analisei.
Qualquer verificação feita em código de aplicação, "verifico se já votou, se não votou, salvo", tem uma janela de corrida. Dois requests chegam ao mesmo tempo, ambos passam pela verificação sem encontrar voto, ambos tentam salvar. Em servidores com mais de uma instância rodando (que é qualquer ambiente de produção com auto-scaling ou dois workers), isso não é teórico. É recorrente.
A única garantia atômica e consistente é a que vive no banco de dados:
CONSTRAINT votos_eleitor_segmento_unico UNIQUE (cpf_hash, segmento_id)Uma linha. Essa constraint faz com que o Postgres garanta, no nível de storage, com lock de linha, sem condição de corrida possível, que o par (eleitor, segmento) nunca aparece duas vezes. Se dois requests concorrentes tentam inserir o mesmo par, um recebe erro 23505. O outro passa. Sem duplicata, independente de quantas réplicas da API estão rodando.
O padrão é o mesmo que uso para idempotência de pagamentos: quando um invariante precisa ser real, ele mora no banco. Não no cache. Não no código. No banco, porque o banco é a única fonte de verdade atômica que você tem.
O resto do sistema, auth, anti-fraude, filas, admin, existe para fazer a experiência boa e detectar padrões suspeitos. Mas a unicidade do voto, a propriedade central do sistema, é garantida por constraint. Não por lógica de negócio.
O pico do último dia e por que sua arquitetura vai cair nele
Todo sistema com prazo concentra a maior parte do tráfego nas últimas horas. Não é especulação, é padrão documentado em qualquer sistema que lida com deadline, de imposto de renda a Black Friday. Sessenta por cento dos votos do Diamante do Ano provavelmente chegam no último dia.
Isso cria um problema arquitetural específico: o comportamento do sistema no dia normal não prevê o comportamento no dia que importa.
Se a API escreve votos sincronamente, recebe request, abre conexão com banco, insere, fecha, responde, ela vai funcionar perfeitamente com cem votos por hora e começar a derreter com dois mil. As conexões com o banco enchem o pool. Queries lentas começam a empilhar. O tempo de resposta sobe. Os usuários ficam na tela de carregamento e clicam de novo. Cada clique adicional agrava o problema.
A solução não é escalar o banco horizontal, é separar receber o voto de persistir o voto.
Quando o usuário clica em votar, a API valida a sessão, calcula o risco, e joga o voto numa fila. Resposta imediata para o usuário: "voto recebido". Um worker processa a fila em ritmo controlado, dez por segundo, cem por segundo, o que o banco aguenta tranquilo. O pico no frontend não chega ao banco como pico.
O benefício secundário: se o banco cair por dez minutos no último dia, os votos ficam na fila. Quando o banco volta, o worker processa normalmente. Sem perda. Sem usuário que votou e não teve o voto contabilizado. Sem suporte recebendo ligação às 23h reclamando que o site estava fora.
Votos não são transações de pagamento, uma latência de alguns segundos entre "cliquei" e "contabilizado" é completamente aceitável. Usar isso a favor da arquitetura é a diferença entre um sistema que aguenta o pico e um que cai exatamente quando mais importa.
LGPD: o cenário do vazamento como design driver
A forma errada de pensar sobre LGPD em projetos assim é como checklist de conformidade. A forma certa é como exercício de design: qual é o título da manchete se esse banco de dados vazar?
Para o Diamante do Ano, a manchete seria: "Evento de Diamantina expõe CPF e voto de 50 mil moradores da cidade."
Dois problemas nesse título: CPF exposto é dano à pessoa. Voto exposto é quebra de sigilo. Um sistema bem arquitetado torna essa manchete impossível mesmo no pior cenário de segurança.
Isso leva a duas decisões técnicas que não são opcionais:
A primeira é que CPF nunca é armazenado em claro. O que o banco conhece é um hash com salt, uma sequência de caracteres que não reverte para o CPF original nem por força bruta, porque o salt secreto é necessário para reproduzir o hash. Se o banco vazar, não vaza CPF. Vaza hashes inúteis sem o salt.
A segunda é que os dados de identidade e os dados de votação vivem separados. O serviço de autenticação sabe o CPF hash e o celular. O serviço de votos sabe o CPF hash e em quem votou. Nenhum serviço, sozinho, tem os dois. Uma query que junte "quem é essa pessoa" com "em quem ela votou" não pode existir na arquitetura.
Isso não é paranoia, é o mesmo princípio que o TSE usa na urna eletrônica. O voto é separado da identificação do eleitor antes de ser contabilizado. A ideia tem décadas, funciona, e é aplicável mesmo num sistema pequeno como esse.
O admin panel é onde o sistema vive ou morre
Todo sistema de votação sem painel de administração em tempo real está voando às cegas. Isso é onde eu vi mais sistemas falharem, não na arquitetura do voto, mas na operação.
Durante o período de votação, o operador precisa enxergar: volume de votos por hora (para detectar rajadas suspeitas), distribuição por segmento (para identificar segmentos com padrão anômalo), e uma fila de votos com score de risco alto aguardando revisão humana.
Esse último ponto é importante: votos suspeitos não devem ser bloqueados automaticamente. Eles devem ser sinalizados para revisão. Um voto com score alto pode ser um fraudador, ou pode ser o dono do estabelecimento votando de madrugada porque esqueceu durante o dia. Automatizar o bloqueio sem revisão humana vai criar reclamações legítimas de empresas que perderam votos por falso positivo.
O audit log é a peça que amarra tudo. Cada voto registrado, cada ação administrativa, cada voto invalidado, com timestamp, autor, e motivo. Imutável: só inserts, nunca updates. Se uma empresa contestar o resultado, você tem a trilha completa para defender. Sem audit log, você tem um resultado sem evidência. Com audit log, você tem um processo auditável.
O diploma na parede da empresa ganhadora vale na proporção direta da confiança no processo que o gerou. Confiança em processo é produto de auditabilidade. Auditabilidade é produto de design, não de boa vontade.
Stack técnica
NestJS + TypeORM
API principal com módulo de auth separado do módulo de votos. TypeORM para as constraints de banco, a UNIQUE de unicidade vive aqui.
PostgreSQL 16
Fonte de verdade. UNIQUE(cpf_hash, segmento_id) garante unicidade de voto com garantia atômica. Schemas separados para auth e votação por design LGPD.
BullMQ + Redis
Fila de processamento que desacopla recebimento de persistência. Redis também para contadores em tempo real do ranking ao vivo.
FingerprintJS Pro
Device fingerprinting no frontend. Um dos sinais mais confiáveis no score de risco, dispositivo muda muito menos que IP e é mais difícil de falsificar.
Cloudflare
Primeira linha de defesa: bot score nativo, DDoS protection, e rate limiting por IP antes de tocar na API. Zero código de aplicação para a camada de rede.
Next.js
SSR para as páginas de segmento e empresas (SEO e performance de carregamento inicial), client-side para o fluxo de votação com UX responsiva.
O que sistemas de votação ensinam sobre software em geral
Sistemas de votação são um microcosmo de todos os problemas difíceis de software.
Você tem invariantes que precisam ser verdadeiros sob concorrência, e descobre que a maioria das proteções em código de aplicação tem janelas de corrida que você só encontra sob carga real. Você tem um modelo de ameaça não-óbvio, e descobre que proteger contra o adversário errado cria falsos positivos que machucam o usuário legítimo mais do que o fraudador. Você tem requisitos de auditabilidade, e descobre que o sistema de registro do que aconteceu é tão importante quanto o sistema que faz acontecer.
A lição central é sobre onde colocar confiança. Código de aplicação pode ter bug. Cache pode estar desatualizado. Servidor pode ter dois workers rodando. O banco de dados com constraint é a única coisa que aguenta concorrência real sem condição de corrida.
Quando eu penso em qualquer sistema agora, pagamentos, inventário, reserva de vagas, a primeira pergunta é: qual é o invariante central, e o que garante esse invariante de forma atômica? Se a resposta é "o código verifica antes de inserir", eu preciso repensar. Se a resposta é "constraint no banco", eu posso dormir tranquilo.
O Diamante do Ano de Diamantina não precisa de engenharia de Google. Precisa de engenharia certa: cada decisão técnica motivada pelo problema real, cada garantia colocada na camada que realmente pode oferecê-la, e um sistema que o operador consegue entender e auditar quando alguém contestar o resultado.
Toda essa conversa, na prática, se resume a isso:
try {
await em.insert(Voto, { cpfHash, segmentoId, empresaId, riskScore })
} catch (e) {
if (e.code === "23505") throw new ConflictException("Voto já registrado")
throw e
}Oito linhas. O banco garante o invariante. O código captura a violação. O resto da arquitetura, auth, fila, anti-fraude, admin panel, existe para fazer a experiência boa e detectar padrões suspeitos. Mas a propriedade central do sistema mora nessa constraint, não no código.
Esse é o trabalho.