Rumo ao Hexa? A Arquitetura Hexagonal (Parte 2)
Fala pessoal! Neste post vamos abordar o desenvolvimento de uma aplicação na abordagem tradicional e, logo em seguida, quais seriam as diferenças desta para a arquitetura hexagonal. No fim, iremos discutir se algumas abordagens aplicadas valem a pena e quais possíveis casos de aplicação desta arquitetura.
Se você ainda não está muito familiarizado com os conceitos hexagonais, sugiro ir no post anterior (Parte 1) e, logo em seguida, voltar para cá.
Este tutorial será feito em SpringBoot + Kotlin. Caso goste de uma abordagem relacionada ao NodeJS, recomendo ver o tutorial do Guerreiro Programador.
A Aplicação
Imagine que alguns consoles não sejam fabricados no Brasil e precisem ser importados para serem vendidos por aqui. Um vendedor gostaria de saber qual seria o preço de venda do console que lhe desse uma margem de lucro. Suponha que a aplicação tenha três principais funcionalidades:
- Calcular o lucro de um console passando nome, preço em dólar e preço de uma possível venda;
- Cadastrar o produto passando o nome, preço em dólar e % do lucro esperado;
- Consultar produtos pelo identificador gerado.
Os cálculos têm como base a taxa de conversão do dólar e a taxa de IPI (imposto sobre produtos importados). Na aplicação, estes valores serão recuperados de sistemas externos (neste caso, mocks). O cálculo em si será feito na camada de service.
Aplicação tradicional
A aplicação terá uma entidade ConsolePrice
que será retornada ao cliente com os valores da conversão do dólar, valor e taxa do IPI, taxa e valor do lucro e o preço de revenda.
Repare que ela está anotada com valores relacionadas à persistência (Entity, Id, GeneratedValue) e com serialização (JsonInclude). Isso facilitará o trabalho na hora de persistir e enviar a informação sem objetos nulos ao cliente, respectivamente.
Existem mais duas classes referentes aos requests: ConsolePriceCalculateRequest
e ConsolePriceRegisterRequest
. A diferença entre os requests é um atributo: o calculate
tem nome, preço em dólar e o valor de revenda em reais para calcular quanto lucro o revendedor terá. Já o register terá a porcentagem de lucro ao invés do valor de revenda em reais.
Estes requests serão usados na camada de service. O registerConsolePrice
calcula um ConsolePrice
e salva no repositório. O calculateProfitFromBRL
apenas calcula o ConsolePrice
a partir do preço de revenda mas não salva. O Service também possui um método para consultar o ConsolePrice
do repositório. Vale salientar que o Service possui dependências de serviços para recuperar a taxa de conversão do dólar e do percentual de imposto do IPI.
Em resumo, o cálculo recupera a taxa de conversão, o percentual do imposto e aplica estes valores no preço do console em dólar para converter em reais. Caso seja uma requisição para cadastrar o produto, o cálculo adiciona um valor percentual (recebido via requisição) referente ao lucro do produto . A fórmula pode ser definida como:
precoBRL = precoUSD * conversaoDolar * (1+ pctgIPI) * (1+ pctgLucro)
Em caso de ser apenas um cálculo, o valor de revenda já é passado na requisição, ou seja, calculamos os valores de porcentagem do lucro e o valor.
Neste exemplo, os valores de taxa de conversão e porcentagem do IPI são fixos: 5.43 e 0.4, respectivamente. Eles aparecem em classes concretas que implementam as interfaces ExchangeRateService
e TaxService
.
A camada de Repository é bem simples usando a JPA
O Controller também é tranquilo com as anotações do Spring Web.
Lembrando que como o ConsolePrice
tem a anotação de não incluir atributos nulos e o id é inserido apenas no banco, o endpoint /calculate
retornará um console price sem id (afinal, retornar null é dose né?), enquanto que uma consulta no endpoint /console/{id}
retornará com o id.
A estrutura de classes da arquitetura tradicional seria mais ou menos assim:
Você ficou curioso como ficaria uma aplicação com arquitetura hexagonal? Teve algum insight do que seria modificado? Vamos lá!
Aplicação com a arquitetura hexagonal
Podemos começar a pensar na aplicação hexagonal a partir do core da aplicação, que ficará desacoplado. Vamos criar um package core
com uma estrutura assim:
Em comparação à aplicação anterior, podemos manter a entidade ConsolePrice
, porém sem as anotações referentes à persistência e serialização. Podemos deixar outra camada lidar como vai persistir o dado ou como vai integrar com sistemas externos (pode ser JSON, byteArray, XML). Neste exemplo, deixamos um atributo id nulo como default para dar suporte à adapters de persistência (afinal o id é o principal atributo para recuperar o valor). Poderíamos pensar em criar dois objetos: um com e outro sem id, porém poderia ser purista demais (e essa discussão vai continuar até o fim desse artigo).
Além disso, podemos inserir comportamentos dentro do domínio. O cálculo que fazíamos no ConsolePriceService
do exemplo tradicional pode ser movido para o domínio da aplicação. Criamos então o ConsolePriceCalculator
(parece nome de um produto das Organizações Tabajara).
Repare que não usamos nada relacionado à ConsolePriceCalculateRequest
e ConsolePriceRegisterRequest
, ou seja, o domínio fica o mais desacoplado possível do que virá. Ele recebe apenas os parâmetros do ConsolePrice
. A única diferença é que o calculateFromProfit
recebe o % do lucro como parâmetro e o calculateFromPriceInBRL
recebe o preço em reais.
O domínio está ok, vamos para as input ports.
Como falado na seção anterior, a aplicação tem três principais funcionalidades: calcular lucro, cadastrar produto e consultar produto salvo. Então o caminho feliz é o seguinte: vamos criar três input ports como interface para cada use case, e três implementações dos use cases.
O model
tem as classes de request. O port
possui as interfaces para entrada (in) e saída (out). Enquanto isso, o usecase
possui as implementações das portas de entrada, ou seja, os use cases em si. As input ports (interfaces) ficam assim:
Como dito, são interfaces que se relacionam diretamente às funcionalidades da aplicação e recebem as requests como parâmetro.
Na outra ponta, os use cases chamam serviços externos por output ports. Tais portas são configuradas também por responsabilidade única e separadas por interfaces:
Neste exemplo, FetchExchangeRateOutputPort
e FetchTaxPercentageOutputPort
buscam informações da conversão do dólar e da porcentagem do IPI, respectivamente. As operações de persistência são divididas entre o SaveConsolePriceInputPort
e o LoadConsolePriceInputPort
.
As use cases seguem uma implementação parecida com o ConsolePriceService
com duas modificações: sem fazer os cálculos (já tá definido no domínio) e definindo uma use case para cada funcionalidade, mantendo a “S”ingle Responsibility e a “I”nterface Segregation Principle do SOLID.
É interessante notar que cada use case depende apenas do ConsolePriceCalculator
(domínio) e de output ports. O fluxo da aplicação fica melhor definido e com menos lógica técnica (movida para o domínio) e chamadas para integrações com sistemas externos. O método register
do RegisterConsolePriceUseCase
parece seguir a definição de alto nível vinda do backlog: recupere o valor da taxa de conversão, o valor da porcentagem do IPI, calcule o preço do console, salve no repositório e retorne o valor.
Falta agora vermos como ficam os adapters. Vamos criar um package com três subpackages: integration, repository e web.
Começando de cima para baixo, os adapters de integration são exatamente iguais ao do exemplo tradicional (qualquer dúvida só dar uma olhadinha lá). A camada de repository, por outro lado, muda bastante. Talvez seja o ponto mais polêmico dessa arquitetura.
Vamos seguir uma ideia de implementação do blog reflectoring.io. Ao invés de uma interface que herda do JpaRepository
como no exemplo anterior, precisamos incrementar este adapter com outros componentes. Lembram que o ConsolePrice
não possui annotations? Agora precisamos criar uma classe que, de fato, possa ser persistida no banco. Além disso, precisaremos mapear a classe de domínio para a classe a ser persistida. Um possível exemplo está definido abaixo.
A classeConsolePriceJpa
é bem parecida com o ConsolePrice
do exemplo anterior, exceto pela falta do JsonInclude
. As funções do ConsolePriceMapper
são bem simples: apenas recebem um objeto e retornam o outro.
A classe ConsolePriceRepository
também é bem diferente do exemplo anterior. Ela recebe o mapper e o ConsolePriceJpaRepository
como dependências e implementam as funções definidas pelo SaveConsolePriceInputPort
e pelo LoadConsolePriceInputPort
, mantendo o ISP do SOLID.
Ao invés de apenas chamar o save()
e o findById()
do JpaRepository
, vamos precisar converter o objeto ConsolePrice
para um ConsolePriceJpa
na hora de persistir, além de fazer o inverso na hora de recuperar. No exemplo anterior não tínhamos o problema de retornar um objeto que não existisse devido ao Optional
que pode retornar objetos vazios. Porém, caso nós queiramos converter um objeto que não foi retornado, ocasionaráNullPointerException
. Desse modo, criamos uma Exception chamada ConsolePriceNotFoundException
na camada de domain para evitarmos o problema.
A camada web está praticamente igual ao exemplo anterior. Nós verificamos se a exceção foi retornada na consulta e retornamos um objeto vazio. Além disso, dependemos de todos os usecases agora e não dos services. Aqui, se fôssemos mais puristas, poderíamos criar um controller para cada funcionalidade também.
Precisamos agora injetar as dependências do core na aplicação spring. Vamos criar um package config e lá adicionar a classe CoreInjection
:
Um agradecimento especial ao Victor Rattis que levantou a possibilidade de mudar a abordagem anterior que usava a use case com dependências do Spring.
Para finalizar, precisamos apenas ignorar atributos nulos no retorno do JSON. O ideal é não fazer isso no domínio, já que podemos dar suporte à diferentes serializações e, em algum caso, seja interessante retornar um null ao invés de remover o atributo. O Spring tem uma opção que usaremos aqui por ser mais fácil de aplicar. Basta adicionar o spring.jackson.default-property-inclusion=non_null
no application.properties e os retornos do JSON vão ignorar os atributos nulos! Essa opção deve ser usada com cuidado pois vai mudar todos os retornos.
Pronto! A aplicação hexagonal está de pé! Dá uma olhada na estrutura de classes da application:
e da core:
De fato, parece que escrevemos um pouco mais de código nessa abordagem.
Discussões
Gente, precisamos de mappers?
Eu sei que essa pergunta vai ser a mais falada quando vemos essa arquitetura de cara. Para uma arquitetura limpa, o ideal é que eles existam, mas vale a pergunta: qual será o impacto dos mappers no desenvolvimento? Imagine que você persista e recupere 22 entidades no seu projeto. Já viu que vai dar trabalho, né?
Caso a aplicação seja muito específica e não tenha indícios que o repositório será modificado, pode ser que valha a pena manter uma estrutura sem mappers. Por outro lado, como algumas coisas não estão escritas em pedra, é legal você manter um domínio desacoplado sem anotações @Document, por exemplo, já que isso pode permitir diversas estruturas de persistência no futuro ou salvar apenas campos específicos do seu objeto de domínio. No fim, cabe tomar a decisão baseada no valor que este desacoplamento pode agregar.
Domínio puro?
Um domínio puro facilita o uso dele em diferentes projetos. Por exemplo, no contexto de microsserviços, é fácil termos um package commons
em praticamente todos os serviços com entidades e algumas possíveis funções. O menor número de dependências anotadas do Spring e do Jackson, por exemplo, diminui o tamanho do package e evita acoplamentos com determinados frameworks. O tradeoff é justamente o que vimos até agora: criação de mappers, repetição de código em diferentes projetos e serialização.
É válido serializar fora do objeto?
Aqui vai uma opinião pessoal (e que fique bem claro que isso é uma opinião): sim. O uso de JsonIgnore
e derivados pode virar um caos no futuro. Imagine que uma classe deve ser serializada com camel case, outra deve ignorar nulos, outra deve falhar em atributos desconhecidos… ou então que você espere um atributo no Swagger e ele simplesmente não aparece. O ideal para mim é a aplicação tentar seguir uma estratégia de serialização única, definindo um mapper para toda a aplicação. Essa estratégia se alinha com a arquitetura hexagonal.
Caso você integre com diferentes aplicações (ex: uma aceita valores camelCase, outra só snake case), o melhor a se fazer seria criar mappers específicos para cada integração. Desse modo, um ConsolePrice
puro poderia ser usado com diferentes serializações para um retorno de API via camelCase ou cadastrar em uma outra aplicação que recebe apenas como snake case.
E os princípios do SOLID?
Percebemos que a arquitetura hexagonal se alinha aos princípios do SOLID e sugere que deixemos a aplicação o mais desacoplada possível. Isso de fato é uma vantagem em comparação à abordagem tradicional. A aplicação de use cases e o uso de portas fortalece os princípios de Open Closed Platform e o da Interface Segregation principle, sem falar da injeção de dependências (o da substituição de Liskov ainda causa confusão e, por isso, vou deixar de fora :D). Vale salientar que estes conceitos também podem ser aplicados em outras abordagens, ou seja, não é preciso ser hexagonal para usar SOLID, com certeza.
Porém, será que aquele Injectable que fizemos quebra o Open-closed Platform do SOLID? É uma discussão interessante, visto que o Kotlin só permite estender classes com oopen
associado. De fato, não foi a melhor abordagem, mas sabemos que dá pra injetar dependências de forma a não quebrar o OCP.
E tem como ser ainda mais purista?
Sempre dá, né! Como disse no texto, podemos dividir o controller por use case, criando três controllers diferentes. Outra opção seria criar um adapter de apresentação ao invés de retornar a mensagem para o Controller diretamente. Se você reparar direitinho, o Controller é um adapter de entrada, então por que receber uma saída da aplicação? A Clean Architecture prega o uso de um presenter, que é chamado pelo use case via uma output port. Nesse ponto acho que já é purismo demais… o que acha?
Ps: Para descontrair, recomendo um episódio do podcast Devs Cansados intitulado “Purista é muito chato” . E ouça de coração leve pq você pode discordar de algumas coisas :D
Quando é indicado usar?
Acredito que, caso sua aplicação tenha muitas integrações, persista muitos dados, tenha uma regra de negócio pouco complexa e dependendo de uma única estrutura de banco de dados, não vale a pena. A quantidade de mappers talvez seja desnecessária para as consultas no banco e o domínio não precise ser tão desacoplado assim.
Caso sua aplicação pretenda ser usada em diferentes ambientes (ex: por CLI, API, UI) ou seja usada como uma lib em demais projetos e tenha uma regra de negócio complexa, acredito que é bem válida. Um exemplo disso seria uma aplicação para realizar simulações complexas. Podemos gerar uma lib que possa ser usada em outro projeto Java, acessá-la via CLI, UI e salvar seus resultados em diferentes repositórios.
Se você chegou até aqui, muito obrigado! Espero que a discussão tenha sido bastante rica e que possamos sempre pensar duas vezes antes de tomar a arquitetura como a salvadora dos nossos problemas.
Estou aberto a sugestões: críticas e conselhos são muito bem vindos!
Tanto o código da aplicação tradicional quanto o da hexagonal estão no Github.
Aqui abaixo deixo algumas fontes para reforçar seus estudos.
Muito obrigado! Forte abraço e fiquem bem.