Aplicação full-stack de contatos e compromissos com autenticação, domínio 100% livre de framework, Result pattern, 172 testes e 10 ADRs documentados. A demo percorre o produto; a faixa de observabilidade conta o primeiro dia em produção visto pelos logs.
Problema, arquitetura, decisões e o que foi medido — tudo rastreável às fontes listadas no fim. Sem números inventados.
01Problema
O projeto nasceu de um CRUD de tutorial. A auditoria inicial encontrou uma falha grave: a coleção de contatos não possuía campo de proprietário — qualquer usuário lia, editava e apagava contatos de todos, e a página inicial listava a base inteira para visitantes anônimos. O objetivo passou a ser reconstruir até o nível que produção exige: fronteiras arquiteturais verificáveis, segurança como requisito com testes de regressão de vulnerabilidade, observabilidade, infraestrutura como código e cada decisão relevante registrada como ADR com o trade-off explícito.
02Contexto
Node.js 20+, Express 4, EJS renderizado no servidor, MongoDB 7 com Mongoose 7, sessão com express-session + connect-mongo. Sem framework de frontend por decisão: o produto é server-side rendered e o JavaScript do cliente é progressivo — sem ele, todos os formulários continuam funcionando. O ambiente publicado roda no Fly.io; a infraestrutura AWS (ECS Fargate, ALB, VPC, Secrets Manager) existe em Terraform, é validada em CI e nunca foi aplicada — declarado no README.
03Arquitetura
Hexagonal (Ports & Adapters) em quatro camadas concêntricas — domain, application, infrastructure, presentation — mais shared transversal, com toda dependência apontando para o domínio. A inversão acontece nas portas: o domínio declara interfaces (ContatoRepository, ServicoDeHash) e a infraestrutura as implementa. A regra é testável, não uma promessa: grep -r "mongoose|bcrypt|express" src/domain src/application não retorna nada.
diagrama SVG inline — sem biblioteca, herda o tema da página
domain
Entidades (Usuario, Contato, Compromisso, RegistroDeAuditoria), value objects (Email, Telefone, Etiquetas, DataHora…), portas de persistência e de hashing. Sem I/O.
application
Casos de uso, DTOs e Result. Sem framework.
infrastructure
Adaptadores: Mongoose, bcrypt, pino, container de DI.
presentation
Adaptador HTTP: Express, EJS, middlewares.
shared
Result, hierarquia de erros, cancelamento, contexto de execução.
04Decisões e trade-offs
decisão 01
Hexagonal em vez de Clean ou Onion (ADR-0006): as três impõem a mesma regra de dependência; a escolha é de vocabulário. Clean separaria "Interface Adapters" de "Frameworks & Drivers", que aqui colapsam (o adaptador HTTP é o Express); Onion destacaria um anel de Domain Services quase vazio (há um serviço de domínio puro). Hexagonal nomeia exatamente o que existe: um dentro, um fora, portas e adaptadores.
em vez de
Manter o monólito organizado por tipo (ADR-0001) — tecnicamente defensável pelo custo de manutenção para 4 entidades.
trade-off
De 31 para 72 arquivos em src/; indireção (rota → controller → caso de uso → entidade → repositório → mapper); cerimônia em casos triviais; curva de entrada. Preço declarado justo para o objetivo (peça avaliada por engenharia) — não seria para um MVP de duas semanas.
evidência
docs/adr/0006-arquitetura-hexagonal.md — os 43 testes de integração pré-existentes passaram sem uma linha alterada.
decisão 02
Autorização no filtro da consulta (ADR-0002): toda consulta a contatos inclui o dono vindo exclusivamente da sessão; a propriedade virou invariante do agregado.
em vez de
Middleware que carrega o recurso e compara o dono (opt-in — basta esquecer numa rota nova); plugin global do Mongoose (comportamento invisível ao depurar).
trade-off
Cada método recebe userId, poluindo um pouco a assinatura; um futuro papel de administrador exigirá um caminho separado e explícito. Em troca: impossível esquecer, mesmo 404 para "não existe" e "não é seu" (impede enumerar ids), e o índice composto serve a autorização e o desempenho.
Result<T> em vez de exceções para falha prevista (ADR-0007): validação, regra de negócio violada, não encontrado e cancelamento são valores de retorno; banco fora, bug e estado impossível são exceção.
em vez de
Either de fp-ts/neverthrow (vocabulário funcional inteiro para o que o projeto usa); convenção [erro, valor] (não compõe); exceções para tudo (caminho de erro invisível na assinatura).
trade-off
Verbosidade (if (r.isFailure) return r; frequente); sem tipos estáticos nada impede acessar .value de uma falha — mitigado lançando TypeError, erro imediato e barulhento em vez de undefined silencioso.
evidência
docs/adr/0007-result-pattern.md
decisão 04
Cancelamento cooperativo com AbortSignal por requisição (ADR-0008): res.on("close") + AbortSignal.timeout(30s) propagados de middleware a driver, com throwIfAborted, runCancellable e maxTimeMS no Mongo; cancelamento classificado como HTTP 499, fora da faixa 5xx.
trade-off
Limite honesto: em Node não há como interromper computação em andamento de fora — bcrypt.hash/compare n ão é cancelável, justamente a operação que satura a event loop sob ataque (mover para worker thread está no roadmap, não implementado). signal como último parâmetro polui as portas; esquecer de propagá-lo quebra a cadeia em silêncio.
evidência
docs/adr/0008-cancelamento-cooperativo.md · 14 testes unitários + 6 de integração provam a propagação ponta a ponta
decisão 05
Trilha de auditoria com contexto via AsyncLocalStorage (ADR-0010): requestId, usuarioId, IP e user agent são metadado transversal; dado de negócio (o dono do contato) continua parâmetro explícito. Porta AuditoriaRepository sem atualizar/remover — append-only imposto pela forma da interface; retenção por índice TTL (180 dias, configurável).
em vez de
Passar os metadados por parâmetro em toda a cadeia.
trade-off
Falha de auditoria não derruba a operação (best-effort — insuficiente para auditoria de conformidade, que exigiria mesma transação ou Outbox); tentativa de login com e-mail inexistente não entra na trilha, para não construir uma base de endereços tentados; overhead do AsyncLocalStorage em toda requisição.
Sessão no servidor em vez de JWT (ADR-0005): revogação imediata é uma escrita; cookie httpOnly sem token no cliente; a aplicação é server-side rendered e cada requisição já consulta o banco.
em vez de
JWT stateless, escolha frequente em projetos de portfólio.
trade-off
Toda requisição autenticada faz uma leitura na coleção de sessões.
evidência
docs/adr/0005-sessao-no-servidor-em-vez-de-jwt.md
decisão 07
Injeção de dependências manual (ADR-0009): Container próprio (~50 linhas) com registro explícito em composicao.js, sem framework.
em vez de
Awilix (auto-resolução por nome de parâmetro — renomear quebra em runtime), InversifyJS/tsyringe (decorators + reflect-metadata), require direto (anula a inversão), cabeamento no server.js.
trade-off
Cada caso de uso novo exige uma linha de registro (erro no boot, não em tempo de escrita); sem detecção automática de ciclo; resolução por string sem verificação estática. Gatilho para revisitar: grafo > ~30 nós ou escopo por requisição.
evidência
docs/adr/0009-injecao-manual-de-dependencias.md
05Implementação
›Autenticação com bcrypt, regeneração de sessão no login (anti session fixation), política de força de senha e defesa contra enumeração de contas; helmet, express-rate-limit e CSRF próprio (ADR-0003, no lugar do csurf descontinuado).
›Busca com escape de metacaracteres (proteção contra ReDoS); exportação CSV com BOM UTF-8, separador ; e neutralização de fórmulas (CSV injection), além de JSON — toda exportação entra na trilha.
›Calendário mensal renderizado no servidor no fuso America/Sao_Paulo; compromisso com vínculo opcional a um contato e verificação de posse.
›A aplicação recusa iniciar se qualquer variável obrigatória estiver ausente ou fraca, listando o que corrigir; seed e migrations idempotentes; healthz (sem banco) e readyz (com banco).
›Sanitização recursiva dos detalhes da auditoria (chaves como password, token, _csrf viram [REDACTED], até 3 níveis).
›Imagem Docker multi-stage, usuário não-root e health check embutido.
06Testes
172 testes (~25 s) com node:test, supertest e mongodb-memory-server — 99 unitários sem I/O e 73 de integração com MongoDB em memória.
›Testes de segurança como regressão de vulnerabilidade: cada um reproduz o ataque concreto que a arquitetura impede (ex.: outro usuário não consegue editar contato alheio).
›Autorização: 10 testes de integração (leitura, edição, exclusão, listagem, mass assignment do campo user, acesso anônimo).
›Cancelamento: 14 unitários + 6 de integração — o sinal atravessa controller → caso de uso → repositório → driver, e escrita cancelada não persiste.
›Auditoria: invariantes, sanitização recursiva, append-only, isolamento de contexto entre escopos concorrentes, "senha em texto puro nunca chega à trilha".
›Fronteira arquitetural verificável por grep em src/domain e src/application; roteiro de carga em k6 presente em tests/load (sem resultados publicados).
07Observabilidade
Logs estruturados com pino/pino-http e uma trilha de auditoria que o próprio usuário consulta — log e trilha são coisas diferentes, e o projeto as separa de propósito.
›Primeiro dia em produção visto pelos logs: pico de 404 causado por scanners procurando arquivos de segredo (/.env, /.netrc, /.vscode/settings.json), cada um respondido com erro tipado e tempo de resposta medido.
›requestId correlaciona trilha de auditoria e log operacional na investigação de incidente.
›Cancelamento registrado como HTTP 499 — fechar a aba não consome error budget nem dispara alarme de disponibilidade.
›Tentativa de login com e-mail inexistente aparece no log operacional em warn (retenção curta), não na trilha (dado pessoal consultável pelo titular).
08Resultados e aprendizados
✓A falha de autorização foi corrigida como invariante do agregado, não com um if a mais — e é protegida por 10 testes de regressão que impedem merge se falharem.
✓A refatoração para Hexagonal preservou comportamento: os 43 testes de integração pré-existentes passaram sem alteração; 51 testes unitários rodam em menos de 100 ms.
✓172 testes, 10 ADRs com alternativas e consequências negativas assumidas, deploy contínuo com domínio próprio e HTTPS.
✓Aprendizado: declarar limites (bcrypt não cancelável, auditoria best-effort, Terraform validado mas não aplicado) é parte da engenharia — "afirmar que toda operação aceita cancelamento seria falso precisamente onde a diferença mais importa".
09Fontes
Tudo o que está escrito acima é rastreável a estes arquivos e repositórios.