💬 0

Design: Ports, Adapters & Vertical Slices

Olá!

Este é mais um post da seção Design, e nele vou falar sobre dois padrões que, quando combinados, costumam dar um salto real de qualidade em sistemas de software: Ports and Adapters e Vertical Slices.

Gosto de pensar neles como duas ideias complementares. Uma fala sobre como isolar o domínio da aplicação e reduzir o acoplamento com o mundo externo. A outra fala sobre como organizar o código em torno de funcionalidades e não em torno de camadas tecnológicas. Juntas, elas deixam a aplicação mais fácil de evoluir, testar e manter.

Quando a aplicação se organiza por camadas de forma rígida, como com Presentation, Application, Domain e Infrastructure, é muito comum ver um código em que cada camada conhece as demais e o domínio fica preso a detalhes de infraestrutura. Isso gera fricção, acoplamento desnecessário e dificuldade de mudar uma coisa sem mexer em outras. Além disso, é comum neste modelo de camadas horizontais tenhamos classes de serviço enormes, contendo diversos métodos, que são difíceis de ler e manter – além de um incentivo aos modelos anêmicos

Ports and Adapters ajuda a resolver parte do problema, Vertical Slices ajuda a resolver outra, e quando combinamos os dois, torna-se muito mais fácil manter o domínio isolado, as interações com o mundo externo mais facilmente testáveis, e manter o código conciso, com módulos auto-contidos e pequenos que aumentam a legibilidade e reduzem a carga cognitiva.

Parace mágico. Não? E é mesmo! Vou demonstrar ao longo do post.

Vamos lá!

O que é Ports and Adapters?

O padrão Ports and Adapters, também conhecido como Arquitetura Hexagonal, foi proposto por Alistair Cockburn em 2005. O problema que ele visava responder era o excesso de acoplamento entre camadas e a baixa testabilidade. A ideia central se mostrou simples: o núcleo da aplicação não deve depender diretamente de detalhes externos como banco de dados, fila, Web API e afins. Em vez disso ele depende de contratos explícitos que podem dizer duas coisas: 1) como a aplicação deve ser invocada pelo mundo externo e; 2) como ela demanda recursos do mundo externo.

Esses contratos são as portas (Ports), interfaces que indicam, cada uma, uma chamada possível para a aplicação ou da aplicação. Os contratos que tratam das chamadas possível à aplicação são chamadas de driver ports, enquanto as que representam chamadas que partem da aplicação são as driven ports. Não precisa se preocupar em memorizar esses nomes, no fim são todas portas e é isso que nos importa.

O ponto mais importante aqui é: dado que essas portas são interfaces é possível simular seu comportamento em casos de teste muito facilmente. Precisa testar uma Web Api? Use um mock! Precisa testar um banco de dados? Use TestContainers. Precisa testar o domínio apenas? Crie dummies de entrada. Simples e fácil.

Um exemplo clássico é um caso de uso de criação de pedido:

public interface IOrderRepository
{
    Task SaveAsync(Order order, CancellationToken cancellationToken);
}

public sealed class CreateOrderHandler
{
    private readonly IOrderRepository _orderRepository;

    public CreateOrder(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<Order> ExecuteAsync(CreateOrderRequest request, CancellationToken cancellationToken)
    {
        var order = new Order(request.CustomerId, request.Items);

        await _orderRepository.SaveAsync(order, cancellationToken);

        return order;
    }
}

Neste exemplo, o caso de uso não sabe nada sobre infraestrutura. Ele só sabe que precisa salvar um pedido, e esse contrato está definido pela interface IOrderRepository.

E aqui chegamos à segunda parte do nome do pattern: os adaptadores (Adapters)

Um adaptador é uma implementação de uma porta. Ele é responsável por "traduzir" uma mensagem vinda do mundo externo para que o domínio a entenda ou uma mensagem do domínio para o mundo externo.

Pra ajudar a pensar: Endpoints no ASP.NET são adaptadores. Eles traduzem uma mensagem vinda como um tipo ajustado para eles, e.g. CreateOrderRequest e são traduzidos para uma mensagem que o domínio entenda como, por exemplo, CreateOrderCommand.

Isso é importante porque reduz o acoplamento. O domínio deixa de depender de detalhes de infraestrutura e passa a terceirizar essas preocupações para os adaptadores. Como já disso, isso aumenta demais a testabilidade, facilitando a manutenção do domínio e até mesmo a substituição de tecnologias de infra no projeto.

O que é Vertical Slice?

Vertical Slice é uma forma de organizar o código em torno de funcionalidades, e não em torno de camadas. Em vez de um projeto dividido em Controllers, Services, Repositories etc, você organiza por funcionalidade que o criador do pattern, Jimmy Bogard, chamou de fatia (Slice).

Lembra que disse acima que classes de serviço tendem a ser grandes? Isso acontece porque elas orbitam uma entidade e não uma funcionalidade que dela dependa. Isso leva a situações como a observada abaixo:

public class TodoService(ITodoRepository repository) : ITodoService
{
    public Task<IReadOnlyList<TodoItem>> ListAsync() => 
        repository.ListAsync();

    public async Task<Guid> CreateAsync(CreateTodoDto dto)
    {
        ...
    }

    public async Task CancelAsync(Guid id)
    {
        ...
    }

    public async Task CompleteAsync(Guid id)
    {
        ...
    }
}

Ou seja, tudo o que é relativo a uma entidade está concentrado em uma única classe, o que dificulta a leitura, aumenta a carga cognitiva e aumenta o acoplamento entre as funcionalidades.

Uma slice vertical vai no sentido oposto, dividindo pra conquistar. Tudo que uma funcionalidade precisa fica junto: comando, handler, validação, domínio, persistência etc. Tudo visando um único caso de uso.

Uma estrutura comum seria algo assim:

src/
  Features/
    CreateTask/
      CreateTaskCommand.cs
      CreateTaskHandler.cs
      ICreateTaskDatabasePort.cs
      CreateTaskDatabaseAdapter.cs
    CancelTask/
      CancelTaskCommand.cs
      CancelTaskHandler.cs
      ICancelTaskDatabasePort.cs
      CancelOrderDatabaseAdapter.cs

Mas a ideia principal não é a pasta em si, nem deveria ser, sejamos honestos. A ideia é a organização conceitual: uma funcionalidade deve ter tudo o que precisa para existir e evoluir em um único lugar, com forte coesão interna e baixa dependência externa.

Isso contrasta com a organização em camadas, em que o mesmo conceito “pedido” (ou "task" como nos exemplos seguintes) aparecem em múltiplos lugares e em muitas dimensões diferentes. Em arquitetura em camadas, você pode ter OrderController, OrderService, OrderRepository, OrderDto, OrderEntity, OrderMapper espalhados por várias pastas. Vertical Slice tenta corrigir isso. Ela reconhece que a mudança no software normalmente vem de um comportamento do usuário ou de um caso de uso. O código então deve ser organizado em torno disso.

É muito comum ouvir pessoas falando que Vertical Slices “reduzem o acoplamento entre módulos” e “aumentam a coesão”. Isso é verdade, mas a relevância real está em outra coisa: a organização do código reflete melhor o que o sistema faz. Quando o sistema muda, ele muda por funcionalidade, e não por camada.

Agora perceba o seguinte: além de dizer adeus às classes de serviços grandes, todas as classes grandes deixam de existir. Repare que temos dois adaptadores de banco de dados*, ambos podem utilizar a mesma infraestrutura mas, terão seus próprios métodos, sem a injeção de um repositório inteiro.

Nota: * Aqui chamei de DatabaseAdapter apenas para fins didáticos, essas classes são os bons e velhos repositórios!

A combinação de Ports and Adapters com Vertical Slices

Entendo que essa combinação seja ideal para a imensa maioria dos casos -- senão todos!

A ideia é esta:

  • cada Slice encapsula um caso de uso ou fluxo de negócio;
  • o domínio da Slice define as Portas que ela precisa;
  • os Adaptadores da Slice implementam as dependências externas;
  • a organização por feature mantém o código próximo ao problema de negócio;
  • o isolamento do domínio mantém o negócio livre de infraestrutura e altamente testável.
  • o desenvolvedor precisa ler muito menos código para alterar o que precisa.

Um exemplo simples:

public readonly record struct CreateTaskCommand(string Title);

public interface ICreateTaskDatabasePort
{
    Task SaveAsync(TodoItem item);
}

public sealed class CreateTaskHandler(ICreateTaskDatabasePort port)
{
    public async Task<Guid> HandleAsync(CreateTaskCommand command)
    {
        var item = new TodoItem(command.Title);
        await port.SaveAsync(item);
        return item.Id;
    }
}

public sealed class CreateTaskDatabaseAdapter(InMemoryDatabase database) : ICreateTaskDatabasePort
{
    public Task SaveAsync(TodoItem item)
    {
        database.Save(item);
        return Task.CompletedTask;
    }
}

A slice CreateTask tem:

  • a request;
  • o handler;
  • o domínio TodoItem;
  • a porta ICreateTaskDatabasePort;
  • o adaptador CreateTaskDatabaseAdapter;
  • bônus classes de teste;

A lógica de criação da task fica em uma única área. A persistência fica fora, mas não no coração do sistema. Em vez de um “serviço de domínio global” ou um “repositório global”, você tem um contrato que faz sentido para aquela funcionalidade e para o contexto daquela slice.

Isso produz um sistema com mais clareza. Quando eu preciso entender como uma task é criada, eu abro a slice CreateTask e consigo compreender toda a funcionalidade. Não preciso atravessar seis pastas e cinco camadas só para perceber o fluxo inteiro.

Vantagens da combinação

A vantagem mais clara é que você ganha coesão, isolamento e redução de carga cognitiva ao mesmo tempo.

Confira em detalhes abaixo.

1. Domínio protegido de infraestrutura

Com Ports and Adapters, o domínio não depende de banco de dados nem de framework. Com Vertical Slices, a funcionalidade é organizada em torno do problema de negócio. O resultado é um núcleo que consegue evoluir em conjunto com a regra de negócio, sem ficar preso a tecnologia.

2. Testes mais diretos

Quando o sistema está bem dividido, você consegue testar a slice inteira sem o peso de toda a infraestrutura. Você testa o comportamento de uma funcionalidade em um único lugar. E como as dependências externas são portas, você pode substituir a infraestrutura por stubs, fakes, implementações em memória ou TestContainers.

Isso é muito importante. Testes em arquitetura em camadas muitas vezes exigem você montar um ecossistema inteiro para validar uma regra simples. Com slices e ports, a regra é mais evidente.

3. Mudanças mais localizadas

Se você precisa alterar a forma como o pedido é persistido, o impacto da mudança fica restrito à implementação do adaptador ou à infraestrutura da slice, e não no código de domínio que define a regra. Se você precisa ajustar a regra de criação do pedido, essa mudança fica no centro da funcionalidade, sem espalhar efeitos colaterais por toda a aplicação.

4. Menos abstração inútil

Um erro comum é transformar tudo em abstrações genéricas e criar “interfaces para tudo”. Isso costuma gerar uma camada de indireção que não traz benefício real. Vertical Slices ajuda a evitar isso porque a abstração fica aliada ao caso de uso. Você não cria porta para qualquer coisa; você cria contrato para aquilo que realmente é uma dependência externa da funcionalidade.

5. Organização que reflete o problema

Arquitetura em camadas é útil para entender o sistema em um nível geral. Vertical Slices é útil para entender a funcionalidade. A combinação entre os dois permite ter a visão global e, ao mesmo tempo, uma organização que faça sentido para aquilo que o negócio precisa.

Onde a combinação funciona melhor

Eu vejo essa combinação funcionando melhor em sistemas com várias funcionalidades e domínio bastante específico, principalmente quando há:

  • regras de negócio não triviais;
  • necessidade de testes de comportamento;
  • múltiplas integrações externas;
  • evolução constante do domínio;
  • mais de um tipo de entrada e saída;
  • necessidade de sair da “camada de serviço genérica”.

Ou seja, ela funciona bem em sistemas de média a alta complexidade, especialmente quando a organização em camadas começa a virar um peso.

Também funciona em monólitos modulares, em serviços menores, e em sistemas que separam por contexto de negócio. O grande benefício é que o sistema fica mais previsível. O problema não fica escondido na camada de baixo; a regra continua na slice e a tecnologia fica do lado de fora.

Conclusão

Entendo que o melhor desenho é aquele que reduz a carga cognitiva e torna o sistema mais fácil de compreender, testar e evoluir -- também entendo que a combinação de Ports and Adapters com Vertical Slices faz isso muito bem.

Ports and Adapters e Vertical Slices não são apenas formas de escrever código, são formas de pensar!

Ter sistemas simples, muito coesos e pouco acoplados ajudam a reduzir o estresse da manutenção o que, por consequência, ajuda a reduzir seu risco e também seu custo.

Como sempre o código de exemplo do artigo está disponível no Github, esteja à vontade para baixar e testar.

Gostou? Me deixe saber pelo indicadores, comentários ou mesmo pelas minhas redes sociais.

Nos vemos no próximo post!

Comentários (0)

Deixe seu comentário

Nenhum comentário ainda. Seja o primeiro a comentar!