Design: Ports and Adapters (Arquitetura Hexagonal)

Olá!

Este é mais um artigo da seção Design, e nele falaremos sobre um padrão muito útil chamado Ports and Adapters, também conhecido como Arquitetura Hexagonal

Vamos lá!

O qué o padrão Ports and Adapters?

É um padrão proposto por Alistair Cockburn para reduzir o acoplamento entre as diferentes camadas de um sistema, aumentando assim sua testabilidade.

O padrão recebe este nome por conta da forma como uma aplicação que o utilize interage com o mundo externo: a partir de ports (portas) e adapters (adaptadoes).

Portas são um meio de comunicação com o mundo externo à aplicação (que você pode entender como seu domínio), e podem ser de dois tipos: primárias e secundárias.

As portas primárias, representam as entradas da aplicação, conhecem os contratos do domínio e são oferecidas, geralmente, como casos de uso ou serviços de aplicação. Essas portas costumam ser envolvidas por componentes que conversam com o mundo exterior diretamente, e que fazem uma eventual tradução entre o formato de sua própria entrada e o formato do domínio. Esses componentes que envolvem essas portas primárias são os chamados adaptadores, dada a sua função de interagir com o mundo externo suprindo o domínio com os dados que este demanda.

Quando o padrão foi proposto, foi pensado que as portas primárias se conectariam ou à camada de apresentação da aplicação, ou seja, a camada de interação com o usuário, ou a outras aplicações que se integrariam à aplicação em questão.

Soa confuso? Vamos a um exemplo simples.

Imagine que sua aplicação é um agendador de tarefas que deve aceitar a criação de tarefas por meio de uma API Web. A porta de entrada, se implementada como um caso de uso, teria a seguinte forma:

public class CreateTaskUseCase : ICreateTaskUseCase
{
    private readonly IMyTaskDataAccess _dao;
    ...

    public MyTask CreateTask(string title, string description, DateTime dueDate)
    {
       var task = MyTask.Create(title, description, dueDate);
       _dao.Insert(task);
       return task;
     }
}

Para utilizar este caso de uso, a Web API o envolverá por meio de injecão de dependência, em um controller que o invocará mediante uma requisição, como no exemplo abaixo:

...
public class CreateTaskController : ControllerBase
{
    private readonly ICreateTaskUseCase _useCase;

    public CreateTaskController(ICreateTaskUseCase useCase) =>
        _useCase = useCase;

    [HttpPost]
    public IActionResult Create[FromBody] CreateTaskRequest request)
    {
         try
         {
             var task = _useCase.CreateTask(request.Title, request.Description, request.DueDate);
             return Ok(task);
         }
         catch(CreateTaskException exc)
         {
             return BadRequest(exc.Message);
         }
         catch
         {
             return InternalServerError();
         }
    }
}

Repare, então, que temos uma porta primária, o caso de uso de criação da tarefa e posterior persistência, e um adaptador correspondente, o controller, que abastecerá o domínio da aplicação com os dados enviados pelo consumidor da API Web.

Já as portas secundárias se apresentam de maneira distinta. Elas costumam ser interfaces que expõem um contrato demandado pelo domínio para se comunicar com o mundo externo visando, ou a saída de dados proveniente de seu processamento, ou a satisfação de alguma dependência do mundo externo que, eventualmente, uma porta primária venha a ter (como, por exemplo, o consumo de outra aplicação).

Um exemplo de porta secundária é a interface IMyTaskDataAccess usada pelo CreateTaskUseCase para inserir no banco de dados uma tarefa recém-criada. Esta interface é definida na aplicação (domínio) e implementada por quem a utiliza – em nosso caso de exemplo a API Web, sendo esta implementação um adaptador.

Por que usar Ports and Adapters?

A maior vantagem do uso do padrão é o isolamento do domínio, que leva a uma maior testabilidade.
Observando o exemplo acima, podemos ver que é possível testar nosso caso de uso muito facilmente, da seguinte forma:

public class CreateTaskUseCaseTest
{
    [Fact]
    public void CreateTaskSuccessfully()
    {
        //Arrange
        var dao = new InMemoryMyTaskDatabase(); //Implementa IMyTaskDataAccess
        var sut = new CreateTaskUseCase(dao);

        //Act
        var task = sut.CreateTask("title", "description", DateTime.Today.AddDays(1));

        //Assert
        Assert.Equal("title", task.Title);
        Assert.Equal("description", task.Description);
        Assert.Equal(DateTime.Today.AddDays(1), task.DueDate);
    }
}

Note que, uma vez que o teste é realizado contra o caso de uso, nossa porta primária, e utilizando um mock para a porta secundária, não precisamos nos preocupar com a forma como se dará a entrada de dados no domínio, nem mesmo com o mecanismo de persistência para a tarefa criada. Desta forma, temos nosso domínio isolado, e uma forma de atestar o funcionamento de sua lógica sem a exigência de qualquer dependência externa.

Ou seja, tanto do ponto de vista dos testes, quanto da execução da aplicação, não há diferença se a entrada de dados vai se dar por uma API Web, um dispositivo móvel, terminal ou pelo consumo de uma fila. Da mesma forma, não importa qual banco de dados está sendo usado, se está em uso ou não algum ORM, qual o mecanismo de mensageria utilizado para publicar eventos ou disparar comandos, nem mesmo qual serviço está sendo usado para enviar e-mails de notificação.

Tudo o que a aplicação conhece são suas portas, e cabe a quem for consumir a aplicação implementar os adaptadores correspondentes.

Quando usar Ports and Adapters

Como já sabemos, nenhuma solução é adequada a todos os cenários – e, claro, isso se aplica a este padrão da mesma forma.

Em um cenário orientado a serviços, onde cada serviço representa um contexto delimitado da aplicação, é uma ótima opção – sim, DDD faz um casamento muito bom o padrão por ser orientado a domínio!

Mesmo não utilizando DDD, serviços que representem módulos de uma aplicação também se beneficiam deste padrão, uma vez que seu escopo reduzido torna mais facilmente mapeáveis as entradas e saídas possíveis.

Um bom critério para saber se o uso do padrão faz sentido é o número de casos de uso que um domínio apresenta, e quais as saídas (ou dependências) possíveis para cada um.
No caso de exemplo acima, de agendamentos de tarefas, temos poucos casos de uso -- a criação da tarefa, seu reagendamento e seu cancelamento, por exemplo. Enquanto isso, temos apenas uma saída, a persistência de seu estado.

É claro que, no mundo real, aplicações podem ser mais complexas que nosso simples agendador de tarefas mas, de todo modo, avaliar a quantidade de casos de uso e saídas segue sendo um bom parâmetro. Ou seja, no final das contas, grosso modo, quanto mais alto o número de possíveis portas, menor a viabilidade do emprego do padrão.

Mas, por que Arquitetura Hexagonal?

Este é um ponto bastante interessante. A intenção de Cockburn quando usou um hexágono para ilustrar seu padrão era a de romper com a visão vertical proposta pelo estilo de organização em camadas, deixando de lado a aparente hierarquia que ela apresenta (Apresentação -> Negócio -> Infraestrutura) por um modelo que sugere a centralidade do domínio, e superfícies de contato com o mesmo igualmente relevantes.
Ou seja, cada hexagono que representa uma aplicação, representa um domínio cercado por componentes que com ele interagem. Se pensarmos em várias aplicações integradas, teremos um mapa com diversos hexágonos onde cada um oferece uma superfície de contato para outro através de seus diferentes lados.

Interessante. Não?

Conclusão

O padrão Ports and Adapters é muito útil para elevar a testabilidade do domínio, e é flexível o bastante para ser combinado com outros padrões de modo a satisfazer as necessidades da aplicação. Seu fit ideal é com pequenas aplicações ou serviços, mas pode ser usado em casos mais complexos caso se perceba que seu custo é superado pelos ganhos que oferece.

Como de costume, segue um exemplo de código no Github, com um exemplo bastante simples de integração entre três serviços. Todos os serviços implementam o padrão, e são acompanhados de casos de teste para demonstrar como a testabilidade do domínio é beneficiada. Claro que, por ser um exemplo, os casos de teste serão muito simples, mas entendemos ser o suficiente para demonstrar o valor do padrão.

Gostou? Me deixe saber pelos indicadores. Tem alguma dúvida ou consideração? Deixe um comentário por aqui ou em alguma de minhas redes sociais.

Muito obrigado por chegar até aqui, e até a próxima!

Referências
Alistair Cockburn - Hexagonal Architecture

20