Padrão de arquitetura hexagonal - AWS Orientação prescritiva

As traduções são geradas por tradução automática. Em caso de conflito entre o conteúdo da tradução e da versão original em inglês, a versão em inglês prevalecerá.

Padrão de arquitetura hexagonal

Intenção

O padrão de arquitetura hexagonal, também conhecido como padrão de portas e adaptadores, foi proposto pelo Dr. Alistair Cockburn em 2005. O objetivo é criar arquiteturas com acoplamento fraco em que os componentes da aplicação possam ser testados de forma independente, sem dependências de armazenamentos de dados ou interfaces de usuário (UIs). Esse padrão ajuda a evitar o bloqueio tecnológico de armazenamentos de dados e interfaces de usuário. Isso facilita a alteração da pilha de tecnologia ao longo do tempo, com impacto limitado ou inexistente na lógica de negócios. Nessa arquitetura com acoplamento fraco, a aplicação se comunica com componentes externos por meio de interfaces chamadas portas e usa adaptadores para converter as trocas técnicas com esses componentes.

Motivação

O padrão de arquitetura hexagonal é usado para isolar a lógica de negócios (lógica de domínio) do código de infraestrutura relacionado, como o código para acessar um banco de dados ou APIs externas. Esse padrão é útil para criar lógica de negócios e código de infraestrutura com acoplamento fraco para funções do AWS Lambda que exigem a integração com serviços externos. Nas arquiteturas tradicionais, uma prática comum é incorporar a lógica de negócios na camada do banco de dados como procedimentos armazenados e na interface do usuário. Essa prática, junto com o uso de constructos específicos de UI na lógica de negócios, leva a arquiteturas estritamente acopladas que causam gargalos nas migrações de bancos de dados e nos esforços de modernização da experiência do usuário (UX). O padrão de arquitetura hexagonal permite que você projete seus sistemas e aplicações por propósito e não por tecnologia. Essa estratégia resulta em componentes de aplicações facilmente intercambiáveis, como bancos de dados, UX e componentes de serviço.

Aplicabilidade

Use o padrão de arquitetura hexagonal quando:

  • Você deseja desacoplar a arquitetura da sua aplicação para criar componentes que possam ser totalmente testados.

  • Vários tipos de clientes podem usar a mesma lógica de domínio.

  • Seus componentes de interface de usuário e banco de dados exigem atualizações periódicas de tecnologia que não afetem a lógica da aplicação.

  • Sua aplicação requer vários provedores de entrada e consumidores de saída, e personalizar a lógica da aplicação leva à complexidade do código e à falta de extensibilidade.

Problemas e considerações

  • Design orientado por domínio: a arquitetura hexagonal funciona especialmente bem com o design orientado por domínio (DDD). Cada componente da aplicação representa um subdomínio no DDD, e arquiteturas hexagonais podem ser usadas para obter um acoplamento fraco entre os componentes da aplicação.

  • Testabilidade: por design, uma arquitetura hexagonal usa abstrações para entradas e saídas. Portanto, gravar testes de unidade e testar isoladamente torna-se mais fácil devido ao acoplamento fraco inerente.

  • Complexidade: a complexidade de separar a lógica de negócios do código de infraestrutura, quando tratada com cuidado, pode trazer grandes benefícios, como agilidade, cobertura de testes e adaptabilidade tecnológica. Caso contrário, os problemas podem se tornar complexos de resolver.

  • Sobrecarga de manutenção: o código adicional do adaptador que torna a arquitetura conectável é justificado somente se o componente da aplicação exigir várias fontes de entrada e destinos de saída para gravar, ou quando o armazenamento de dados de entrada e saída precisar mudar com o tempo. Caso contrário, o adaptador se tornará outra camada adicional a ser mantida, o que introduz uma sobrecarga de manutenção.

  • Problemas de latência: o uso de portas e adaptadores adiciona outra camada, o que pode resultar em latência.

Implementação

As arquiteturas hexagonais são compatíveis com o isolamento da lógica de aplicações e negócios do código de infraestrutura e do código que integra a aplicação a interfaces de usuário, APIs externas, bancos de dados e agentes de mensagens. Você pode conectar facilmente os componentes da lógica de negócios a outros componentes (como bancos de dados) na arquitetura da aplicação por meio de portas e adaptadores.

As portas são pontos de entrada independentes de tecnologia em um componente da aplicação. Essas interfaces personalizadas determinam a interface que permite que atores externos se comuniquem com o componente da aplicação, independentemente de quem ou do que implementa a interface. Isso é semelhante à forma como uma porta USB permite que muitos tipos diferentes de dispositivos se comuniquem com um computador, desde que usem um adaptador USB.

Os adaptadores interagem com a aplicação por meio de uma porta usando uma tecnologia específica. Os adaptadores se conectam a essas portas, recebem ou fornecem dados às portas e transformam os dados para processamento adicional. Por exemplo, um adaptador REST permite que os atores se comuniquem com o componente da aplicação por meio de uma API REST. Uma porta pode ter vários adaptadores sem nenhum risco para a porta ou para o componente da aplicação. Para estender o exemplo anterior, adicionar um adaptador GraphQL à mesma porta fornece um meio adicional para os atores interagirem com a aplicação por meio de uma API do GraphQL sem afetar a API REST, a porta ou a aplicação.

As portas se conectam à aplicação, e os adaptadores servem como uma conexão com o mundo externo. Você pode usar portas para criar componentes de aplicações com acoplamento fraco e trocar componentes dependentes mudando o adaptador. Isso permite que o componente da aplicação interaja com entradas e saídas externas sem precisar ter nenhum conhecimento contextual. Os componentes podem ser trocados em qualquer nível, o que facilita os testes automatizados. Você pode testar componentes de forma independente, sem dependências no código da infraestrutura, em vez de provisionar um ambiente inteiro para realizar testes. A lógica da aplicação não depende de fatores externos, então os testes são simplificados e fica mais fácil simular dependências.

Por exemplo, em uma arquitetura com acoplamento fraco, um componente da aplicação deve ser capaz de ler e gravar dados sem conhecer os detalhes do armazenamento de dados. A responsabilidade do componente da aplicação é fornecer dados para uma interface (porta). Um adaptador define a lógica de gravação em um armazenamento de dados, que pode ser um banco de dados, um sistema de arquivos ou um sistema de armazenamento de objetos, como o Amazon S3, dependendo das necessidades da aplicação.

Arquitetura de alto nível

A aplicação ou o componente da aplicação contém a lógica de negócios principal. Ela recebe comandos ou consultas das portas e envia solicitações pelas portas para atores externos, que são implementadas por meio de adaptadores, conforme ilustrado no diagrama a seguir.

Padrão de arquitetura hexagonal

Implementação usando Serviços da AWS

As funções do AWS Lambda geralmente contêm lógica de negócios e código de integração de banco de dados, que são fortemente acoplados para atingir um objetivo. Você pode usar o padrão de arquitetura hexagonal para separar a lógica de negócios do código de infraestrutura. Essa separação permite o teste de unidade da lógica de negócios sem dependências no código do banco de dados e melhora a agilidade do processo de desenvolvimento.

Na arquitetura a seguir, uma função do Lambda implementa o padrão de arquitetura hexagonal. A função do Lambda é iniciada pela API REST do Amazon API Gateway. A função implementa a lógica de negócios e grava dados nas tabelas do DynamoDB.

Implementação do padrão de arquitetura hexagonal na AWS

Código de exemplo

O código de exemplo nesta seção mostra como implementar o modelo de domínio usando o Lambda, como separá-lo do código de infraestrutura (como o código para acessar o DynamoDB) e como implementar testes de unidade para a função.

Modelo de domínio

A classe do modelo de domínio não tem conhecimento de componentes ou dependências externas, ela apenas implementa a lógica de negócios. No exemplo a seguir, a classe Recipient é uma classe de modelo de domínio que verifica se há sobreposições na data da reserva.

class Recipient: def __init__(self, recipient_id:str, email:str, first_name:str, last_name:str, age:int): self.__recipient_id = recipient_id self.__email = email self.__first_name = first_name self.__last_name = last_name self.__age = age self.__slots = [] @property def recipient_id(self): return self.__recipient_id #..... def are_slots_same_date(self, slot:Slot) -> bool: for selfslot in self.__slots: if selfslot.reservation_date == slot.reservation_date: return True return False def is_slot_counts_equal_or_over_two(self) -> bool: #.....

Porta de entrada

A classe RecipientInputPort se conecta à classe do destinatário e executa a lógica do domínio.

class RecipientInputPort(IRecipientInputPort): def __init__(self, recipient_output_port: IRecipientOutputPort, slot_output_port: ISlotOutputPort): self.__recipient_output_port = recipient_output_port self.__slot_output_port = slot_output_port ''' make reservation: adapting domain model business logic ''' def make_reservation(self, recipient_id:str, slot_id:str) -> Status: status = None # --------------------------------------------------- # get an instance from output port # --------------------------------------------------- recipient = self.__recipient_output_port.get_recipient_by_id(recipient_id) slot = self.__slot_output_port.get_slot_by_id(slot_id) if recipient == None or slot == None: return Status(400, "Request instance is not found. Something wrong!") print(f"recipient: {recipient.first_name}, slot date: {slot.reservation_date}") # --------------------------------------------------- # execute domain logic # --------------------------------------------------- ret = recipient.add_reserve_slot(slot) # --------------------------------------------------- # persistent an instance throgh output port # --------------------------------------------------- if ret == True: ret = self.__recipient_output_port.add_reservation(recipient) if ret == True: status = Status(200, "The recipient's reservation is added.") else: status = Status(200, "The recipient's reservation is NOT added!") return status

Classe de adaptador do DynamoDB

A classe DDBRecipientAdapter implementa o acesso às tabelas do DynamoDB.

class DDBRecipientAdapter(IRecipientAdapter): def __init__(self): ddb = boto3.resource('dynamodb') self.__table = ddb.Table(table_name) def load(self, recipient_id:str) -> Recipient: try: response = self.__table.get_item( Key={'pk': pk_prefix + recipient_id})  ... def save(self, recipient:Recipient) -> bool: try: item = { "pk": pk_prefix + recipient.recipient_id, "email": recipient.email, "first_name": recipient.first_name, "last_name": recipient.last_name, "age": recipient.age, "slots": [] } # ...

A função do Lambda get_recipient_input_port é uma fábrica para instâncias da classe RecipientInputPort. Ela cria instâncias de classes de portas de saída com instâncias de adaptador relacionadas.

def get_recipient_input_port(): return RecipientInputPort( RecipientOutputPort(DDBRecipientAdapter()), SlotOutputPort(DDBSlotAdapter())) def lambda_handler(event, context): body = json.loads(event['body']) recipient_id = body['recipient_id'] slot_id = body['slot_id'] # get an input port instance recipient_input_port = get_recipient_input_port() status = recipient_input_port.make_reservation(recipient_id, slot_id) return { "statusCode": status.status_code, "body": json.dumps({ "message": status.message }), }

Teste de unidade

Você pode testar a lógica de negócios para classes de modelo de domínio injetando classes simuladas. O exemplo a seguir fornece o teste de unidade para a classe Recipent do modelo de domínio.

def test_add_slot_one(fixture_recipient, fixture_slot): slot = fixture_slot target = fixture_recipient target.add_reserve_slot(slot) assert slot != None assert target != None assert 1 == len(target.slots) assert slot.slot_id == target.slots[0].slot_id assert slot.reservation_date == target.slots[0].reservation_date assert slot.location == target.slots[0].location assert False == target.slots[0].is_vacant def test_add_slot_two(fixture_recipient, fixture_slot, fixture_slot_2): #..... def test_cannot_append_slot_more_than_two(fixture_recipient, fixture_slot, fixture_slot_2, fixture_slot_3): #..... def test_cannot_append_same_date_slot(fixture_recipient, fixture_slot): #.....

Repositório GitHub

Para uma implementação completa da arquitetura de amostra desse padrão, consulte o repositório do GitHub em https://github.com/aws-samples/aws-lambda-domain-model-sample.

Conteúdo relacionado

Vídeos

O vídeo a seguir (em japonês) analisa o uso da arquitetura hexagonal na implementação de um modelo de domínio usando uma função do Lambda.