Arquitetura
Esta seção descreve a organização interna do Blabry — como o sistema foi decomposto em módulos, quais são as responsabilidades de cada um e quais decisões de projeto sustentam essa divisão.
O critério que orienta todas as decisões aqui é o mesmo: alta coesão dentro de cada módulo e baixo acoplamento entre eles. Um módulo coeso concentra responsabilidades relacionadas; módulos pouco acoplados podem ser alterados de forma independente, o que reduz o custo de manutenção ao longo do tempo.
Visão geral
O sistema é dividido em três projetos independentes, cada um com seu próprio ciclo de build e deploy:
| Projeto | Responsabilidade | Hospedagem |
|---|---|---|
blabry | Interface do usuário (SPA em React) | GitHub Pages |
blabry-api | Regras de negócio e persistência | Railway |
blabry-docs | Documentação do projeto | GitHub Pages |
A separação entre cliente e servidor é total: a comunicação acontece exclusivamente por HTTP sobre uma API REST, e nenhum dos lados conhece detalhes internos do outro. O contrato entre eles é a especificação OpenAPI publicada em /docs.
Arquitetura do backend em camadas
O backend adota uma arquitetura em camadas, na qual cada camada só conhece a camada imediatamente inferior. O fluxo de uma requisição é sempre o mesmo:
Request → Route → Controller → Service → Repository → MySQL
Response ← Controller ← Service ← Repository
| Camada | Responsabilidade | O que não pode fazer |
|---|---|---|
| Routes | Mapear URL e método HTTP para um controller | Conter lógica |
| Controllers | Extrair dados do req, chamar o service, devolver o res | Acessar o banco ou aplicar regras |
| Services | Regras de negócio, validações, orquestração | Escrever SQL |
| Repositories | Única camada com SQL | Conhecer HTTP (req, res, status codes) |
| Models | Estrutura e comportamento dos dados de domínio | Depender de Express ou do driver do banco |
A restrição mais importante é a do repositório: nenhuma query SQL existe fora da camada de repositórios. Essa regra concentra o conhecimento sobre o esquema do banco em um único lugar. Uma mudança de coluna afeta um arquivo, não a aplicação inteira — é a aplicação direta do princípio de ocultamento de informação: o resto do sistema não sabe, e não precisa saber, como os dados estão armazenados.
Por que a camada de serviço existe mesmo quando não faz nada
O countries_service apenas repassa a chamada ao repositório, sem nenhuma regra de negócio. Manter essa camada é deliberado.
A camada existe para preservar a estabilidade do contrato entre camadas. Quando uma regra surgir — um cache em memória para a lista de países, por exemplo —, ela terá um lugar óbvio para morar, e nenhum controller precisará ser alterado. Suprimir a camada por ela estar temporariamente vazia trocaria uma linha de indireção hoje por uma refatoração em três arquivos amanhã.
Decisões de projeto
Orientação a objetos nas camadas de dados
Models e repositórios são implementados como classes; serviços e controllers permanecem como módulos de funções. A escolha não é estética — cada classe existe porque encapsula algo:
- Models encapsulam o formato e as regras dos dados. O model
UserexpõeverifyPassword()e mantém o hash da senha como propriedade não enumerável, de modo que ele não aparece emJSON.stringify, em espalhamento de objeto nem em logs. O risco de vazamento deixa de depender da disciplina de quem escreve cada resposta. - Repositórios encapsulam o acesso ao banco e recebem a conexão pelo construtor.
Onde não há nada a encapsular, não há classe. Objetos literais continuam sendo a escolha correta para estruturas de dados sem comportamento.
Injeção de dependência
Repositórios não importam o pool de conexões diretamente: recebem-no como parâmetro do construtor, e a instância é montada na camada de serviço.
class CountriesRepository {
constructor(pool) { this.pool = pool; }
}
Isso implementa o princípio da inversão de dependência: o repositório depende da abstração "algo que executa queries", não de um módulo concreto. A consequência prática é a testabilidade — um teste automatizado passa um objeto substituto no construtor, sem banco de dados e sem manipular o sistema de módulos do Node.
Padrão Repository
Cada repositório traduz entre dois vocabulários: as colunas do banco (full_name, password_hash, em snake_case) e os atributos do domínio (name, passwordHash, em camelCase). A tradução é feita pelos métodos fromRow() e toRow() do model, concentrando em um ponto único uma conversão que, espalhada, seria uma fonte recorrente de defeitos.
Tratamento centralizado de erros
Nenhuma camada trata o erro localmente. Serviços lançam exceções anotadas com o status HTTP apropriado; controllers apenas repassam com next(err); um único middleware ao fim da cadeia formata a resposta e decide, conforme o ambiente, se a mensagem original pode ser exposta.
O resultado é que a política de erro é definida em um lugar só. Em produção, nenhum stack trace alcança o cliente.
O mascaramento vale só para os 5xx. A primeira versão do middleware escondia toda mensagem em produção, e o efeito foi que "É necessário aceitar a política de privacidade" chegava ao usuário como "Erro interno do servidor". A distinção correta é de origem, não de gravidade: os 4xx são frases escritas para alguém ler e agir; os 5xx carregam mensagem de exceção, que pode expor nome de tabela ou caminho de arquivo.
Limitação de requisições
Um limitador por janela fixa, escrito sem dependência, roda antes das rotas. Cada rota sensível tem o próprio balde: trinta tentativas de login a cada dez minutos, dez cadastros, trinta verificações de código, dez pedidos de e-mail, e um teto geral de seiscentas requisições.
Os baldes são separados por um motivo que só apareceu quando a suíte de testes começou a falhar com 429: com um balde único, cada usuário novo gastaria cadastro, verificação e login do mesmo saldo. Atrás de uma rede compartilhada — universidade, escritório, operadora móvel —, onde centenas de pessoas saem pelo mesmo endereço, isso trancaria a quinta pessoa a se cadastrar.
O limitador não tem dependência porque a aplicação roda em uma instância, onde um contador em memória é exato. Com mais de uma instância nenhum contador local serve, e a resposta é um armazenamento compartilhado — não uma biblioteca diferente.
Ele depende de app.set('trust proxy', 1). Atrás do proxy do Railway, sem essa
configuração, todos os visitantes chegam com o mesmo endereço, dividem um
único balde, e o primeiro a estourar o limite tranca os demais.
Autenticação
A autenticação usa JWT em vez de sessões em servidor. A decisão decorre de uma restrição arquitetural: frontend e backend estão em domínios distintos, e uma solução baseada em cookies de sessão exigiria configuração de terceiros que os navegadores restringem progressivamente.
O token tem validade de 24 horas e viaja no cabeçalho Authorization. O identificador do usuário é sempre extraído do token pelo middleware de autenticação, nunca do corpo ou dos parâmetros da requisição — caso contrário, qualquer cliente poderia agir em nome de outro usuário informando um identificador arbitrário.
O middleware é registrado no server.js após as rotas públicas. Como o Express aplica middlewares apenas ao que é registrado depois deles, a ordem das linhas define quais rotas são públicas:
app.use('/auth', require('./routes/auth_routes'));
app.use('/countries', require('./routes/countries_routes'));
app.use(autenticar); // tudo abaixo exige token
A rota de países é pública por necessidade: ela alimenta o formulário de cadastro, acessado por quem ainda não possui token.
Convenções da API
Idioma da API
A API é inteiramente em inglês: caminhos (/users, /posts, /auth/signup), campos do JSON (name, likes, followers), identificadores do código e colunas do banco.
A versão anterior misturava os dois idiomas — caminhos em inglês, campos em português — sob o argumento de que identificador de recurso e vocabulário de aplicação seriam coisas distintas. Na prática o custo apareceu em cada fronteira: paraPerfil devolvia alias enquanto toJSON devolvia apelido, dois nomes para o mesmo dado, e ninguém percebeu por meses. Um idioma só elimina a categoria inteira de erro.
Português permanece em dois lugares, de propósito: nas mensagens de erro, que são lidas pelo usuário final, e nos comentários do código, que são lidos por quem o mantém. Nenhum dos dois é interface de programação.
A tradução completa está em back_end/docs/VOCABULARIO.md, e é a fonte única para os dois repositórios.
Paginação
Toda listagem é paginada e devolve a mesma estrutura, independentemente do recurso:
{ "posts": [ ... ], "page": 1, "totalPages": 4, "total": 37 }
A uniformidade permite que o cliente consuma qualquer listagem com o mesmo componente e o mesmo hook, sem tratamento específico por rota.
Duas restrições acompanham a paginação:
- O parâmetro
limittem teto definido no servidor. Sem esse limite, um cliente poderia solicitar a base inteira em uma requisição. - Toda consulta paginada declara ordenação explícita. Sem
ORDER BY, o SGBD não garante ordem estável entre páginas, e o mesmo registro pode aparecer duas vezes ou nenhuma.
Autoria e prazos
Operações de edição e exclusão restringem a autoria na própria instrução SQL — WHERE id = ? AND user_id = ? — em vez de verificar antes e agir depois. Não existe intervalo entre a verificação e a escrita, e portanto não existe estado que possa mudar entre as duas.
Consultas anteriores à operação, quando existem, servem apenas para distinguir a resposta: se nada foi afetado, é preciso saber se o recurso não existe (404) ou se pertence a outra pessoa (403). Elas informam a mensagem; não autorizam a ação.
Prazos — como a janela de quinze minutos para edição — são avaliados pelo relógio do servidor. A interface esconde a opção quando o prazo expira, mas isso é conveniência: o relógio do cliente é ajustável, e a regra que vale é a do banco.
Campos atualizáveis
Rotas de atualização parcial recebem apenas os campos que mudaram. O conjunto de campos que podem virar coluna é definido no servidor, em lista explícita, e chaves fora dela são descartadas.
A alternativa — montar a instrução a partir das chaves enviadas pelo cliente — funciona e é comum, mas permite que qualquer coluna da tabela seja escrita, inclusive o hash da senha ou o identificador do registro. Como nome de coluna não pode ser parâmetro de instrução preparada, ele é concatenado à consulta; por isso a regra é que identificadores dinâmicos venham sempre de lista definida no código, nunca da requisição.
Contadores derivados
Curtidas e comentários não são armazenados como colunas nas tabelas de publicação. São obtidos por agregação sobre like_post e comment no momento da consulta.
A alternativa — manter um contador materializado — introduz o risco de divergência entre o contador e os registros que ele deveria refletir. Enquanto a agregação atender aos requisitos de desempenho, ela é preferível por eliminar essa classe de defeito.
Fonte única da lista de países
A tabela countries é a única fonte da lista de nacionalidades. O cliente a obtém por GET /countries em vez de manter uma cópia própria.
A motivação é de integridade: user.nationality é chave estrangeira para countries.country. Uma lista mantida no cliente poderia oferecer valores que o banco rejeita, produzindo falha no cadastro — um defeito causado exclusivamente por duplicação da fonte de verdade.
Segregação de interfaces no perfil
O modelo User define um método de apresentação, paraPerfil({ proprio }),
e é ele quem decide o que sai. No perfil público, email, nascimento e
nacionalidade saem como null; no próprio perfil, saem preenchidos.
A alternativa — devolver tudo e deixar a interface esconder — falha por construção: o dado já saiu do servidor, e qualquer pessoa capaz de abrir as ferramentas do navegador o lê. Esconder na interface é decoração; a decisão tem de acontecer onde a resposta é montada.
Na interface, a seção inteira deixa de ser renderizada em vez de exibir campos vazios. Uma caixa com um travessão anuncia que existe algo escondido ali — o perfil público simplesmente termina na bio.
Verificação por código enviado ao e-mail
Três operações exigem provar acesso à caixa de entrada: ativar a conta,
redefinir a senha e excluir a conta. As três compartilham a mesma tabela
verification_code, distinguidas por uma coluna purpose.
As decisões que sustentam o mecanismo:
- O código é gravado como hash, nunca em texto. Se a tabela vazar, os hashes não abrem conta alguma. O custo do bcrypt é menor que o da senha (8 contra 12), porque aqui a lentidão do hash não é a defesa principal — o limite de tentativas é.
- Cinco tentativas erradas invalidam o código. Seis dígitos são um milhão de combinações, adivinháveis por script em minutos sem esse limite.
- Uso único garantido no
UPDATE. O consumo éUPDATE ... WHERE id = ? AND used_at IS NULL, e o serviço só prossegue se afetou uma linha. Uma checagem anterior seguida de escrita deixaria uma janela onde duas requisições simultâneas passariam. - Respostas uniformes. As rotas de reenvio respondem 200 para e-mail com e sem conta, e as falhas de código — errado, expirado, usado, sem tentativas — compartilham uma única mensagem. Diferenciá-las transformaria as rotas em um verificador de cadastro e diria a quem tenta adivinhar se vale a pena continuar.
- A confirmação é checada depois da senha, no login. Antes dela, o status 403 revelaria a existência da conta a quem não conhece a credencial.
O envio usa Resend, e não Nodemailer. Nodemailer é um transporte SMTP:
ainda exigiria um servidor que entregasse de fato, com reputação de IP, SPF,
DKIM e DMARC. Resend é um provedor — uma chamada HTTP com chave de API, sem
porta SMTP nem conexão persistente. A troca de fornecedor, se ocorrer, mexe em
um arquivo só: config/email.js é a única parte do sistema que conhece o
provedor; o resto conhece apenas sendEmail({ to, subject, html, text }).
Exclusão de conta
DELETE /users/me não apaga a linha do usuário: anonimiza o cadastro e
apaga o conteúdo publicado.
A primeira versão apenas preenchia deleted_at e parava aí. A conta sumia da
aplicação, porque toda consulta já filtra por deleted_at IS NULL, mas nenhum
dado pessoal saía do banco: e-mail, nome completo, data de nascimento,
nacionalidade, bio, foto e o hash da senha continuavam lá. Isso contrariava a
política de privacidade e, como email e alias são únicos, prendia o
endereço para sempre — quem excluísse a conta não conseguiria criar outra com
o mesmo e-mail.
Hoje a operação faz duas coisas:
- Apaga o conteúdo — publicações, comentários, curtidas dadas, relações de seguir e códigos de verificação.
- Anonimiza o cadastro —
emailealiasviram valores derivados doidnum domínio reservado (.invalid), o nome vira "Conta encerrada", e os demais campos pessoais viramNULL. O e-mail e o@originais ficam livres de novo.
Por que anonimizar em vez de apagar a linha. Um DELETE na tabela user
dispararia ON DELETE CASCADE sobre like_post e removeria as curtidas que a
pessoa deu em publicações de terceiros — contadores alheios cairiam
retroativamente, sem que ninguém tivesse pedido nada. Encerrar a própria conta
não pode alterar o conteúdo de outras pessoas. E o artigo 12 da LGPD coloca o
dado anonimizado fora do alcance da lei, então a anonimização cumpre a
obrigação de eliminação em vez de contorná-la.
A operação exige duas provas: o token e o código enviado ao e-mail. Token sozinho prova que a sessão existe, não que o dono está diante da tela — e a ação é irreversível.
Evolução do esquema
O arquivo database_assets/database.sql é o retrato do banco como ele está
hoje. Ele cria tudo do zero e não tem par: a pasta migrations/, que
guardava o caminho de um estado ao outro em arquivos numerados, foi removida.
A separação existiu por um bom motivo e foi encerrada por outro. Ela existiu
porque create table if not exists é silencioso: reexecutar o esquema num
banco em uso não adiciona coluna alguma, ele apenas ignora a tabela inteira, e
uma coluna que existe no arquivo mas não no banco produz uma falha em tempo de
consulta cuja causa não é evidente — o código está certo, o arquivo está
certo, e o que diverge é o banco.
Foi encerrada porque o histórico já vivia em outro lugar. O git é um registro numerado e imutável de todas as alterações, com data, autor e o diff exato; manter uma segunda cópia disso em SQL só criava a pergunta de qual das duas estava certa — e a resposta, na prática, era "depende de qual foi atualizada por último". O projeto tem um único banco em produção, e migrações valem o próprio peso quando há vários ambientes a sincronizar.
O que ficou no lugar delas é um aviso no topo do arquivo: ele cria, não
atualiza. Para evoluir um banco existente, o ALTER é escrito à mão,
comparado com o esquema e conferido com SHOW CREATE TABLE.
Alterações de esquema também têm custo proporcional ao volume da tabela. Adicionar um índice de texto completo reconstrói a tabela; adicionar uma coluna, dependendo da versão do SGBD, também. Com tabelas vazias isso leva frações de segundo; com dados em produção, exige janela de manutenção. Daí a prática de antecipar colunas cuja necessidade já é conhecida, mesmo antes de a funcionalidade existir.
Organização do frontend
O frontend segue uma separação equivalente:
| Pasta | Responsabilidade |
|---|---|
pages/ | Composição de tela e estado da página |
components/ | Componentes reutilizáveis, com responsabilidade única |
hooks/ | Lógica de estado reutilizável, sem apresentação |
services/ | Comunicação HTTP com a API |
styles/ | Tokens de design em custom properties |
A regra correspondente à do repositório no backend é: nenhuma chamada fetch fora de services/. Componentes e páginas consomem funções nomeadas; a URL da API, os cabeçalhos, o tratamento de token expirado e a tradução de códigos HTTP em mensagens ficam concentrados em um módulo.
Hooks como usePaginado isolam o protocolo de paginação: cancelam requisições obsoletas, acumulam ou substituem páginas conforme o caso de uso e expõem apenas o estado que a interface precisa renderizar.