Skip to main content

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:

ProjetoResponsabilidadeHospedagem
blabryInterface do usuário (SPA em React)GitHub Pages
blabry-apiRegras de negócio e persistênciaRailway
blabry-docsDocumentação do projetoGitHub 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
CamadaResponsabilidadeO que não pode fazer
RoutesMapear URL e método HTTP para um controllerConter lógica
ControllersExtrair dados do req, chamar o service, devolver o resAcessar o banco ou aplicar regras
ServicesRegras de negócio, validações, orquestraçãoEscrever SQL
RepositoriesÚnica camada com SQLConhecer HTTP (req, res, status codes)
ModelsEstrutura e comportamento dos dados de domínioDepender 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 User expõe verifyPassword() e mantém o hash da senha como propriedade não enumerável, de modo que ele não aparece em JSON.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 limit tem 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:

  1. Apaga o conteúdo — publicações, comentários, curtidas dadas, relações de seguir e códigos de verificação.
  2. Anonimiza o cadastro — email e alias viram valores derivados do id num domínio reservado (.invalid), o nome vira "Conta encerrada", e os demais campos pessoais viram NULL. 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:

PastaResponsabilidade
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.