Arquitetura hexagonal: separando núcleo de negócio da infraestrutura

1. Fundamentos da Arquitetura Hexagonal (Ports & Adapters)

A Arquitetura Hexagonal, também conhecida como Ports & Adapters, foi proposta por Alistair Cockburn em 2005 como resposta ao problema crônico de acoplamento entre a lógica de negócio e frameworks, bancos de dados e sistemas externos. A motivação central é clara: permitir que o núcleo do software evolua independentemente da tecnologia de infraestrutura.

O nome "hexágono" não tem relação com seis lados obrigatórios — é uma metáfora visual para representar as múltiplas portas de entrada e saída que conectam o núcleo ao mundo externo. Cada lado do hexágono pode representar um tipo diferente de interação: uma interface web, uma API REST, uma fila de mensagens, um banco de dados, um serviço externo.

A diferença fundamental está na direção das dependências. No modelo tradicional, o código de negócio depende diretamente de bibliotecas de banco de dados ou frameworks web. Na arquitetura hexagonal, o núcleo de negócio não conhece nada sobre a infraestrutura — ele define apenas contratos (portas), e os adaptadores implementam esses contratos.

2. Estrutura do Núcleo de Negócio (Core Domain)

O núcleo de negócio contém entidades, objetos de valor e agregados que representam as regras fundamentais do domínio. Essas classes são puras: não possuem anotações de framework, não herdam de classes de infraestrutura e não fazem chamadas a bancos de dados ou serviços externos.

// Entidade de domínio pura
public class Pedido {
    private PedidoId id;
    private List<ItemPedido> itens;
    private StatusPedido status;
    private Dinheiro total;

    public void adicionarItem(Produto produto, int quantidade) {
        if (status != StatusPedido.RASCUNHO) {
            throw new RegraNegocioException("Só é possível adicionar itens em pedidos rascunho");
        }
        itens.add(new ItemPedido(produto, quantidade));
        recalcularTotal();
    }

    private void recalcularTotal() {
        this.total = itens.stream()
            .map(ItemPedido::getSubtotal)
            .reduce(Dinheiro.ZERO, Dinheiro::somar);
    }
}

Os serviços de domínio encapsulam lógica que não cabe naturalmente em uma única entidade. As portas são interfaces que o núcleo define para que os adaptadores implementem:

// Porta de saída definida pelo núcleo
public interface RepositorioPedidos {
    Pedido buscarPorId(PedidoId id);
    void salvar(Pedido pedido);
    void remover(PedidoId id);
}

3. Adaptadores de Entrada (Driving Adapters)

Os adaptadores de entrada são responsáveis por receber requisições do mundo externo e traduzi-las para chamadas ao núcleo de negócio. Controladores REST, handlers de CLI, listeners de fila são exemplos típicos.

// Adaptador de entrada REST
@RestController
public class PedidoController {
    private final CriarPedidoUseCase criarPedidoUseCase;
    private final BuscarPedidoUseCase buscarPedidoUseCase;

    public PedidoController(CriarPedidoUseCase criar, BuscarPedidoUseCase buscar) {
        this.criarPedidoUseCase = criar;
        this.buscarPedidoUseCase = buscar;
    }

    @PostMapping("/pedidos")
    public ResponseEntity<PedidoResponse> criar(@RequestBody CriarPedidoRequest request) {
        Pedido pedido = criarPedidoUseCase.executar(
            new CriarPedidoComando(request.clienteId(), request.itens())
        );
        return ResponseEntity.ok(PedidoResponse.fromDomain(pedido));
    }

    @GetMapping("/pedidos/{id}")
    public ResponseEntity<PedidoResponse> buscar(@PathVariable String id) {
        Pedido pedido = buscarPedidoUseCase.executar(new PedidoId(id));
        return ResponseEntity.ok(PedidoResponse.fromDomain(pedido));
    }
}

O adaptador converte DTOs (objetos de transferência) em objetos de domínio e vice-versa, garantindo que o núcleo nunca seja contaminado por preocupações de serialização ou protocolo HTTP.

4. Adaptadores de Saída (Driven Adapters)

Os adaptadores de saída implementam as portas definidas pelo núcleo. Um repositório JPA, por exemplo, é apenas um adaptador que traduz chamadas de domínio para operações no banco de dados:

// Adaptador de saída JPA
@Component
public class RepositorioPedidosJpa implements RepositorioPedidos {
    private final SpringDataPedidoRepository repository;
    private final PedidoMapper mapper;

    public RepositorioPedidosJpa(SpringDataPedidoRepository repo, PedidoMapper mapper) {
        this.repository = repo;
        this.mapper = mapper;
    }

    @Override
    public Pedido buscarPorId(PedidoId id) {
        return repository.findById(id.valor())
            .map(mapper::paraDominio)
            .orElseThrow(() -> new PedidoNaoEncontradoException(id));
    }

    @Override
    public void salvar(Pedido pedido) {
        PedidoEntity entity = mapper.paraEntidade(pedido);
        repository.save(entity);
    }
}

A inversão de dependência é total: o núcleo define a interface RepositorioPedidos, e o adaptador RepositorioPedidosJpa a implementa. Se amanhã trocarmos JPA por MyBatis ou um banco NoSQL, criamos um novo adaptador sem tocar no núcleo.

5. Configuração e Injeção de Dependências

O container IoC (Spring, Guice, CDI) é o orquestrador que monta o hexágono. Ele injeta os adaptadores concretos nas portas definidas pelo núcleo:

@Configuration
public class ConfiguracaoHexagonal {
    @Bean
    public CriarPedidoUseCase criarPedidoUseCase(
            RepositorioPedidos repositorio,
            ServicoValidacaoPedido validacao) {
        return new CriarPedidoUseCaseImpl(repositorio, validacao);
    }

    @Bean
    @Profile("test")
    public RepositorioPedidos repositorioMock() {
        return new RepositorioPedidosMock();
    }

    @Bean
    @Profile("prod")
    public RepositorioPedidos repositorioJpa(SpringDataPedidoRepository repo) {
        return new RepositorioPedidosJpa(repo, new PedidoMapperImpl());
    }
}

O perigo é vazar configuração de infraestrutura para dentro do núcleo, como anotações @Transactional ou @Cacheable em entidades de domínio. Essas anotações devem ficar exclusivamente nos adaptadores.

6. Testabilidade e Isolamento

A arquitetura hexagonal brilha nos testes. O núcleo pode ser testado unitariamente sem qualquer dependência de infraestrutura:

class CriarPedidoUseCaseTest {
    private RepositorioPedidos repositorioMock;
    private CriarPedidoUseCase useCase;

    @BeforeEach
    void setup() {
        repositorioMock = mock(RepositorioPedidos.class);
        ServicoValidacaoPedido validacao = new ServicoValidacaoPedido();
        useCase = new CriarPedidoUseCaseImpl(repositorioMock, validacao);
    }

    @Test
    void deveCriarPedidoComSucesso() {
        CriarPedidoComando comando = new CriarPedidoComando("cli-1", List.of(
            new ItemComando("prod-1", 2)
        ));

        Pedido pedido = useCase.executar(comando);

        assertThat(pedido.getStatus()).isEqualTo(StatusPedido.RASCUNHO);
        assertThat(pedido.getItens()).hasSize(1);
        verify(repositorioMock).salvar(any(Pedido.class));
    }
}

Testes de integração validam os adaptadores com bancos reais ou serviços mockados, usando contract testing para garantir que os contratos das portas são respeitados.

7. Desafios e Armadilhas Comuns

O principal risco é o over-engineering. Criar portas para cada operação trivial, como buscar uma lista simples, adiciona complexidade desnecessária. A granularidade das portas deve refletir a complexidade do domínio.

Outro desafio é mapear transações entre múltiplos adaptadores. Se um caso de uso precisa salvar em dois bancos diferentes, a consistência transacional exige padrões como Saga ou transações distribuídas.

A decisão de granularidade das portas também é crítica. Uma porta por agregado pode levar a muitas interfaces; portas genéricas demais perdem a clareza dos contratos.

8. Quando a Arquitetura Hexagonal Realmente Vale a Pena

A arquitetura hexagonal é mais adequada para sistemas com múltiplas interfaces (web, mobile, CLI, eventos) e domínios complexos que exigem troca frequente de tecnologia de persistência. Comparada ao MVC tradicional, ela oferece maior isolamento e testabilidade. Comparada à Clean Architecture ou Onion Architecture, é conceitualmente mais simples e focada no padrão Ports & Adapters.

Vale a pena quando:
- O domínio de negócio é rico e mutável
- Múltiplos canais de entrada/saída precisam ser suportados
- A equipe tem maturidade para manter a disciplina de dependências

Não vale a pena em CRUDs simples, protótipos rápidos ou sistemas onde a infraestrutura raramente muda.

Referências