// projeto em produção

Agenda — Arquitetura Hexagonal

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.

Agenda — Arquitetura Hexagonaldemo · 0:54narrado

// o que esta demonstração mostra

  • Contatos, etiquetas e compromissos — com o compromisso entrando no calendário vinculado a um contato.
  • Trilha de auditoria por usuário: quando aconteceu, o que aconteceu e sobre qual objeto.
  • Os dados saem em CSV ou JSON: entrar é fácil; sair também precisa ser.
  • Primeiro dia em produção: pico de 404 de scanners procurando arquivos de segredo — cada um respondido com erro tipado e tempo medido.

// case study de engenharia

Problema, arquitetura, decisões e o que foi medido — tudo rastreável às fontes listadas no fim. Sem números inventados.

Problema

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.

Contexto

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.

Arquitetura

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.

Arquitetura Hexagonal da AgendaUm hexágono central contém domain (entidades, value objects, portas — sem I/O) e application (casos de uso, Result — sem framework). À esquerda, o adaptador de entrada presentation (Express, EJS, middlewares) chama os casos de uso. À direita, os adaptadores de saída da infrastructure implementam as portas declaradas no domínio: Mongoose para ContatoRepository e AuditoriaRepository, bcrypt para ServicoDeHash, pino como logger injetado. A camada shared (Result, erros, AbortSignal, AsyncLocalStorage) atravessa tudo. A regra é verificável: grep por mongoose, bcrypt ou express em src/domain e src/application não retorna nada.domainentidades · VOs · portassem I/Oapplicationcasos de uso · Result · sem frameworkpresentationExpress · EJS · middlewareschamaMongoose · MongoDBContatoRepository · AuditoriabcryptServicoDeHashpinologs estruturados// entrada (driving)// saída (driven): infrastructure implementa as portasshared: Result · hierarquia de erros · AbortSignal (cancelamento) · AsyncLocalStorage (contexto)regra verificável: grep -r "mongoose|bcrypt|express" src/domain src/application → nenhuma ocorrência · DI manual em composicao.js
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.

Decisões e trade-offs

  1. 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.
  2. 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.
    evidência
    docs/adr/0002-autorizacao-no-filtro-da-query.md · tests/integration/contato-authorization.test.js (10 testes)
  3. decisão 03

    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
  4. 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
  5. 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.
    evidência
    docs/adr/0010-trilha-de-auditoria-e-contexto-de-execucao.md · 15 + 7 + 12 testes listados no ADR
  6. decisão 06

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

Implementaçã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.

Testes

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

Observabilidade

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

Resultados 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".

Fontes

Tudo o que está escrito acima é rastreável a estes arquivos e repositórios.

eduardo.valente © 2026 // engenharia em produção