Logo NascimentoIgor Nascimento / Blog
Arquitetura

Ports & Adapters: o core é cego, e isso tem um preço

A promessa da arquitetura hexagonal é que o domínio não sabe quem o chama nem de onde vêm os dados. A parte que ninguém conta é o que você paga por essa cegueira, e quais portas acabam vazando de qualquer jeito.

ArquiteturaBackend9 min de leituraIgor Nascimento

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.

Trade-off

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.

src/ domain/ # entidades e regras puras application/ # casos de uso ports/ inbound/ # o que o core aceita: GridSimulationService outbound/ # o que o core precisa: PriceHistoryRepository, ExchangeGateway adapters/ inbound/ # FastAPI, CLI, consumidor de fila outbound/ # Postgres, Binance, Yahoo Finance, e-mail shared/ # logging, exceções, utilitários

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.

O que eu faria diferente

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