A promessa e a letra miúda
Arquitetura hexagonal, ou ports and adapters, resume-se em uma frase: o core não sabe quem o chama nem de onde vêm os dados. Ele expõe portas de entrada (o que aceita receber) e portas de saída (o que precisa do mundo), e adaptadores plugam essas portas em REST, CLI, fila, banco, exchange, e-mail. Troque o adaptador e o core nem percebe.
A letra miúda é que cegueira custa. Cada porta é uma interface a mais, cada adaptador é uma classe a mais, e cada fronteira esconde detalhes que, em algum momento, alguém vai precisar enxergar. Vale conversar sobre quando esse preço compensa e quais portas, por mais que você tente, vazam.
Onde a hexagonal paga sozinha
Dois cenários onde nunca me arrependi de adotar portas e adaptadores desde o início.
Mais de um canal de entrada para a mesma regra. Em um sistema de trading, a simulação de uma estratégia precisa rodar pela API, por um comando de terminal e por um job agendado. Sem porta de entrada, a regra acaba duplicada ou acoplada ao framework HTTP. Com uma porta GridSimulationService, os três adaptadores são finos e a regra existe uma vez só.
Integração externa que você não controla. A API de uma exchange muda, tem limite de requisições, cai. Com uma porta de saída PriceHistoryRepository, consigo ter um adaptador real para a Binance, um para o Yahoo Finance e um em memória para testes, e trocar entre eles por configuração. Foi assim que o Nahas passou a aceitar três fontes de dados sem tocar no treinamento.
Ganha
- Testes do core com fakes, sem rede nem banco
- Trocar fornecedor vira trocar um adaptador
- Regras existem em um lugar só, com N canais de entrada
- O que é "fora" fica explícito na pasta
adapters/
Paga
- Uma interface e uma implementação por fronteira
- Injeção de dependência para montar tudo
- Leitura do código exige um salto a mais
- Tentação de abstrair o que nunca vai mudar
As portas que vazam
Aqui está a parte que a teoria não discute. Algumas fronteiras não são herméticas por mais que a interface pareça limpa.
Paginação e filtros
Uma porta find_all() é linda até a tabela ter dez milhões de linhas. Aí a porta ganha limit, cursor, order_by, e de repente o core sabe que existe um banco relacional do outro lado. Não há como evitar totalmente. O que faço é aceitar que a porta de leitura expressa capacidades (paginar, ordenar) sem expressar tecnologia (SQL, índice).
Transações
Dois repositórios, uma transação. A interface de cada um não sabe da outra. A solução comum é uma porta de unit of work que o caso de uso abre e passa adiante, e isso significa que o core sabe que "transação" existe. É um vazamento controlado, e prefiro ele explícito a um decorator mágico que abre transação sem ninguém ver.
Erros do fornecedor
A exchange devolve "rate limit" ou "símbolo inválido". Se o adaptador deixa a exceção da biblioteca atravessar, o core está acoplado. Se traduz tudo para uma exceção genérica, perde informação que o caso de uso precisa para decidir se tenta de novo. O meio-termo que adoto: o adaptador traduz para um pequeno conjunto de erros definidos pela porta, do tipo Throttled, Rejected, Unavailable, e o core decide com base neles.
Porta genérica demais (um Repository[T] com CRUD para tudo) parece reutilizável e vira um gargalo, porque cada entidade precisa de uma consulta diferente. Porta específica demais (um método por tela) vira espelho da interface gráfica. O ponto de equilíbrio que uso: uma porta por necessidade do caso de uso, com nome do negócio. PriceHistoryRepository.candles_between(symbol, start, end) diz exatamente o que o core precisa e nada do que o banco faz.
Como organizo na prática
A estrutura que uso segue direto da conversa acima. A diferença em relação a uma Clean Architecture tradicional é que as portas ganham pasta própria, separadas por direção, e os adaptadores também.
Duas regras que não negocio: o core só importa de ports/, nunca de adapters/, e a montagem (quem injeta o quê) acontece em um único lugar, na borda, de preferência em um módulo chamado container ou wiring. Quando a montagem se espalha, a arquitetura já foi.
Serverless muda a conta
Em funções Lambda, cada camada de injeção custa tempo de cold start. Montar um contêiner de dependências pesado a cada invocação fria é dinheiro jogado fora. O que funcionou para mim: manter a hexagonal no código, mas montar o grafo de dependências fora do handler, no escopo do módulo, para que ele seja reaproveitado entre invocações quentes. E ser honesto: uma função de vinte linhas que lê de uma fila e grava em uma tabela não precisa de porta nenhuma.
Durante um tempo testei o core com mocks gerados automaticamente para cada porta. Os testes passavam e o sistema quebrava, porque o mock aceitava qualquer chamada. Hoje escrevo fakes de verdade: um repositório em memória que respeita o contrato, inclusive os erros. Dá mais trabalho uma vez e evita a falsa sensação de cobertura.
Resumo da conversa
- Hexagonal compensa quando há mais de um canal de entrada ou uma integração que você não controla.
- Paginação, transações e erros de fornecedor vazam pela porta. Melhor um vazamento explícito do que um escondido.
- Porta por necessidade do caso de uso, com nome do negócio, nem genérica nem espelho da tela.
- O core nunca importa de adaptadores, e a montagem vive em um lugar só.
Igor Nascimento