Como aplicar o padrão hexagonal em projetos modernos

1. Fundamentos do Padrão Hexagonal (Ports and Adapters)

O padrão hexagonal, também conhecido como Ports and Adapters, foi introduzido por Alistair Cockburn em 2005 como uma resposta ao acoplamento excessivo entre lógica de negócio e infraestrutura. Sua motivação central é permitir que o domínio da aplicação seja testado, evoluído e mantido independentemente de tecnologias externas como bancos de dados, frameworks web ou serviços de terceiros.

A estrutura conceitual divide-se em três partes:

  • Núcleo (Core): contém entidades, objetos de valor e regras de negócio, sem qualquer dependência externa.
  • Portas (Ports): interfaces que definem contratos de entrada (inbound) e saída (outbound) do core.
  • Adaptadores (Adapters): implementações concretas que conectam o core ao mundo externo (REST, banco de dados, filas, etc.).

Diferentemente da clean architecture (Robert C. Martin) e da onion architecture (Jeffrey Palermo), o padrão hexagonal enfatiza a simetria entre portas de entrada e saída, tratando todas as interfaces externas de forma equivalente. Enquanto a clean architecture usa círculos concêntricos, o hexagonal usa um hexágono visual para representar múltiplos pontos de conexão.

2. Projetando o Núcleo do Domínio (Core)

O core deve ser completamente independente de frameworks. Exemplo de entidade sem dependências externas:

public class Pedido {
    private String id;
    private List<ItemPedido> itens;
    private StatusPedido status;

    public Pedido(String id) {
        this.id = id;
        this.itens = new ArrayList<>();
        this.status = StatusPedido.CRIADO;
    }

    public void adicionarItem(Produto produto, int quantidade) {
        if (status != StatusPedido.CRIADO) {
            throw new IllegalStateException("Pedido já finalizado");
        }
        itens.add(new ItemPedido(produto, quantidade));
    }

    public double calcularTotal() {
        return itens.stream()
            .mapToDouble(ItemPedido::getSubtotal)
            .sum();
    }
}

Portas de entrada (inbound ports) representam casos de uso:

public interface CriarPedidoUseCase {
    Pedido executar(CriarPedidoCommand comando);
}

Portas de saída (outbound ports) definem contratos para infraestrutura:

public interface PedidoRepository {
    void salvar(Pedido pedido);
    Optional<Pedido> buscarPorId(String id);
    List<Pedido> listarTodos();
}

3. Implementação de Adaptadores de Entrada (Primary Adapters)

Adaptadores REST traduzem requisições HTTP para chamadas ao core:

@RestController
@RequestMapping("/api/pedidos")
public class PedidoController {
    private final CriarPedidoUseCase criarPedidoUseCase;

    public PedidoController(CriarPedidoUseCase criarPedidoUseCase) {
        this.criarPedidoUseCase = criarPedidoUseCase;
    }

    @PostMapping
    public ResponseEntity<PedidoResponse> criar(@RequestBody CriarPedidoRequest request) {
        CriarPedidoCommand comando = new CriarPedidoCommand(
            request.getClienteId(),
            request.getItens()
        );
        Pedido pedido = criarPedidoUseCase.executar(comando);
        return ResponseEntity.ok(PedidoResponse.fromDomain(pedido));
    }
}

Adaptadores CLI oferecem interface alternativa:

@Component
public class PedidoCliAdapter implements CommandLineRunner {
    private final CriarPedidoUseCase useCase;

    @Override
    public void run(String... args) {
        if (args.length > 0 && args[0].equals("criar-pedido")) {
            CriarPedidoCommand comando = new CriarPedidoCommand(args[1], parseItens(args[2]));
            Pedido pedido = useCase.executar(comando);
            System.out.println("Pedido criado: " + pedido.getId());
        }
    }
}

Adaptadores para filas de mensagens (ex.: RabbitMQ):

@Component
public class PedidoMessageListener {
    private final CriarPedidoUseCase useCase;

    @RabbitListener(queues = "pedidos.criar")
    public void processarMensagem(CriarPedidoMessage mensagem) {
        CriarPedidoCommand comando = new CriarPedidoCommand(
            mensagem.getClienteId(),
            mensagem.getItens()
        );
        useCase.executar(comando);
    }
}

DTOs e validação ficam exclusivamente na camada de adaptação, sem contaminar o core.

4. Implementação de Adaptadores de Saída (Secondary Adapters)

Adaptador de banco de dados SQL:

@Repository
public class PedidoRepositoryJpa implements PedidoRepository {
    private final JdbcTemplate jdbcTemplate;

    @Override
    public void salvar(Pedido pedido) {
        jdbcTemplate.update(
            "INSERT INTO pedidos (id, cliente_id, status, total) VALUES (?, ?, ?, ?)",
            pedido.getId(),
            pedido.getClienteId(),
            pedido.getStatus().name(),
            pedido.calcularTotal()
        );
    }
}

Adaptador para API externa:

@Component
public class NotaFiscalHttpAdapter implements NotaFiscalGateway {
    private final RestTemplate restTemplate;

    @Override
    public void emitir(Pedido pedido) {
        NotaFiscalRequest request = new NotaFiscalRequest(pedido);
        restTemplate.postForEntity("https://api.fiscal.gov/notas", request, Void.class);
    }
}

Adaptador de cache:

@Component
public class PedidoCacheAdapter implements PedidoCachePort {
    private final RedisTemplate<String, Pedido> redisTemplate;

    @Override
    public void armazenar(Pedido pedido) {
        redisTemplate.opsForValue().set("pedido:" + pedido.getId(), pedido, 10, TimeUnit.MINUTES);
    }
}

5. Inversão de Dependência e Injeção de Dependências

A regra fundamental: o core nunca importa classes de adaptadores. Todas as dependências apontam para dentro.

Configuração modular com contêiner DI (Spring Boot):

@Configuration
public class PedidoModuleConfig {

    @Bean
    public CriarPedidoUseCase criarPedidoUseCase(PedidoRepository repository) {
        return new CriarPedidoUseCaseImpl(repository);
    }

    @Bean
    public PedidoRepository pedidoRepository(DataSource dataSource) {
        return new PedidoRepositoryJpa(dataSource);
    }
}

A composição acontece no ponto de entrada da aplicação (classe main ou módulo de configuração), garantindo que o core permaneça puro.

6. Testabilidade e Isolamento com o Padrão Hexagonal

Testes unitários no core sem infraestrutura:

class CriarPedidoUseCaseTest {

    @Test
    void deveCriarPedidoComSucesso() {
        PedidoRepository repository = mock(PedidoRepository.class);
        CriarPedidoUseCase useCase = new CriarPedidoUseCaseImpl(repository);

        CriarPedidoCommand comando = new CriarPedidoCommand("cli-1", List.of(
            new ItemPedidoRequest("prod-1", 2)
        ));

        Pedido pedido = useCase.executar(comando);

        assertNotNull(pedido.getId());
        assertEquals(StatusPedido.CRIADO, pedido.getStatus());
        verify(repository).salvar(pedido);
    }
}

Testes de contrato para adaptadores:

@SpringBootTest
class PedidoRepositoryContractTest {

    @Autowired
    private PedidoRepository repository;

    @Test
    void deveSalvarEBuscarPedido() {
        Pedido pedido = new Pedido("ped-1");
        repository.salvar(pedido);

        Optional<Pedido> encontrado = repository.buscarPorId("ped-1");

        assertTrue(encontrado.isPresent());
        assertEquals("ped-1", encontrado.get().getId());
    }
}

7. Adaptação para Projetos Modernos: Microsserviços e Cloud

Em microsserviços, cada serviço possui seu próprio core e adaptadores:

// Serviço de Pedidos
public class PedidoServiceCore {
    private final PedidoRepository port;
    private final EventPublisher eventPort;
}

// Serviço de Pagamentos
public class PagamentoServiceCore {
    private final PagamentoRepository port;
    private final NotificacaoGateway notificacaoPort;
}

Eventos assíncronos entre serviços via mensageria:

@Component
public class PedidoEventPublisher implements EventPublisher {
    private final RabbitTemplate rabbitTemplate;

    @Override
    public void publicar(PedidoCriadoEvent evento) {
        rabbitTemplate.convertAndSend("pedidos.exchange", "pedido.criado", evento);
    }
}

Adaptadores cloud-native para funções serverless:

public class CriarPedidoFunction implements Function<CriarPedidoRequest, PedidoResponse> {
    private final CriarPedidoUseCase useCase;

    @Override
    public PedidoResponse apply(CriarPedidoRequest request) {
        CriarPedidoCommand comando = new CriarPedidoCommand(
            request.getClienteId(),
            request.getItens()
        );
        Pedido pedido = useCase.executar(comando);
        return PedidoResponse.fromDomain(pedido);
    }
}

8. Boas Práticas e Armadilhas Comuns

Evitar vazamento de infraestrutura para o core: nunca use annotations JPA, anotações de serialização ou dependências de frameworks no domínio.

// ERRADO: core contaminado com JPA
@Entity
public class Pedido {
    @Id
    private String id;
}

// CORRETO: core puro
public class Pedido {
    private String id;
}

Gerenciamento de transações: o controle transacional deve ficar nos adaptadores de saída ou em um adaptador específico, nunca no core.

Quando simplificar: para CRUDs simples sem regras de negócio complexas, o padrão hexagonal pode ser excessivo. Aplique-o quando houver múltiplas fontes de entrada/saída ou regras de negócio que justifiquem o isolamento.


Referências