Criando uma aplicação modular muito além do Clean Architecture

Guilherme Biff Zarelli
luizalabs
Published in
9 min readMar 29, 2021

Objetivo: O objetivo desse artigo é mostrar como uma arquitetura modular baseada em renomadas arquiteturas de software pode tornar seu sistema muito mais durável e flexível.

Inspiração: Esse artigo é inspirado em situações e dificuldades reais já vivenciadas que nos permitiu ter uma visão um pouco mais abrangente sobre como ter e construir uma arquitetura duradoura.

Arquitetura por: Guilherme Biff Zarelli e Alisson Medeiros

Acreditamos que todas as decisões em um projeto de software se reduzem inteiramente em avaliar o valor futuro de uma alteração versus seu esforço de manutenção.

Mas como mudar o crescente gráfico de custo de manutenção em relação ao tempo do projeto ? Porque o custo do ciclo de vida de um produto tende a aumentar em relação a novas features e a produtividade dos desenvolvedores tende a diminuir até chegar a zero e termos que reescrever todo o software por se tornar insustentável? Quem nunca viu um software ter que ser reescrito após aproximadamente dois anos de funcionamento?

Esopo uma vez escreveu uma história muito conhecida da Tartaruga e da Lebre em que podemos resumir sua moral em: quanto mais pressa, menor a velocidade.

Desenvolvedores estão sempre em uma corrida, sendo pressionados por metas agressivas e desafios que os deixam cada vez mais ansiosos, tornando-se Lebres. Eles sabem da importância de um código bom, limpo e bem projetado, mas se iludem com um sonho bem familiar:

“Podemos refatorar tudo depois, primeiro, temos que colocá-lo em produção.”

Infelizmente, sabemos que isso não vai acontecer — salvo raras exceções. A ideia desse artigo e mostrarmos através de conceitos como construir um software muito bem arquitetado para não sermos a Lebre, mas sim a Tartaruga e que mesmo que nossa produtividade seja menor no início do desenvolvimento, no final ela será maior e o software entregará cada vez mais valor com um menor custo de manutenção, tornando-o mais durável e maleável ao tempo e à tecnologia.

“The only way to go fast, is to go well.”
— Robert C. Martin

Arquitetura

O objetivo do bom design de software, como já diria Robert C. Martin, em seu livro Clean Architecture: A Craftsman’s Guide to Software Structure and Design’, é minimizar os recursos humanos necessários para construir e manter um determinado sistema.

Toda e qualquer arquitetura de software deve seguir os mesmos princípios:

  • Independente de estruturas. A arquitetura não depende da existência de alguma biblioteca de software carregada de recursos. Isso permite que você use essas estruturas como ferramentas, ao invés de ter que amontoar seu sistema em suas restrições limitadas.
  • Testável. As regras de negócios podem ser testadas sem a UI, Banco de Dados, Servidor Web ou qualquer outro elemento externo.
  • Independente do banco de dados. Você pode trocar Oracle ou SQL Server, por Mongo, BigTable, CouchDB ou qualquer outro. Suas regras de negócios não estão vinculadas ao banco de dados.
  • Independente de qualquer agente externo. Na verdade, suas regras de negócios simplesmente não sabem absolutamente nada sobre o mundo exterior.

Com esses princípios em mente e com a necessidade de construir algo que perdure por muito tempo, chegamos a seguinte definição das camadas de nossa arquitetura:

Definição das camadas de uma Arquitetura de Software

Em todas as arquiteturas, o objetivo é permitir que as coisas mais estáveis ​​não sejam dependentes de coisas menos estáveis. A camada mais importante e mais estável é o domain, que fica totalmente isolado e independente.

O fluxo de trabalho respeita as tradicionais arquiteturas, como a Onion e Clean Architecture, as camadas externas tem maior privilégio e visibilidade das camadas internas, as camadas internas nunca visualizaram ou implementaram nada das camadas superiores. Dessa maneira, como qualquer boa arquitetura, temos nosso domain (regras de negócio) totalmente isolado de frameworks, serviços, banco, etc.

A idéia é apresentarmos uma proposta de uma arquitetura que respeite os bons princípios de Design de Software para sermos capazes de construir um modelo em que podemos ter n módulos capazes de lidar com diversas situações em escopos distintos e testar diferentes soluções isoladamente. Implementamos a estrutura baseada na Arquitetura Hexagonal por Alistair Cockburn — Vejamos a proposta em um projeto.

Estrutura modular do projeto

Vamos falar de cada módulo e suas responsabilidades levando em consideração a organização apresentada.

Application (app)

Camada onde prevalece o framework e as configuração das dependências das camadas inferiores. Na estrutura apresentada colocamos como exemplo dois módulos, um do Quarkus (quarkus-application) e um do Spring (spring-application), justamente para demonstrar que com a modularização, podemos trabalhar com quaisquer framework, por isso a importância das camadas inferiores serem mais agnósticas possíveis.

Input (adapter/input)

Na camada de apresentação do sistema iremos fornecer nossos end-points. Na estrutura apresentada, o módulo jaxrs-controller-v1 é responsável pela camada REST de entrada. Nele utilizamos apenas dependências da especificação JSR-339 (JAX-RS), a escolha da jax-rs vem da maturidade da especificação e da facilidade de implementação, no Quarkus, por exemplo, basta adicionarmos a dependência resteasy conforme documentação, e no Spring podemos implementa-la com a dependência do spring-boot-starter-jersey . A camada de input também poderia ser responsável por uma leitura de fila, CLI, gRPC, entre outros.

Use Case (core/use-case)

Determina o comportamento da funcionalidade exigida, no caso, a camada de use-case será o orquestrador do domínio ficando responsável por validar as regras de negócios. Ele compartilha essa responsabilidade com as entidades de domínio. Se as regras de negócios forem satisfeitas, o caso de uso manipula o estado do modelo de uma forma ou de outra, com base na entrada. Normalmente, ele mudará o estado de um objeto de domínio e passará esse novo estado para uma porta implementada pelo adaptador de persistência para ser persistido. Um caso de uso também pode chamar quaisquer outros adaptadores de saída, no entanto.

Domain (core/domain)

A camada mais restrita de todas possui o mapeamento de regras e restrições do domínio do negócio. Muitas das regras de negócios estão localizadas nas entidades em vez da implementação do caso de uso, o modelo possui o máximo possível da lógica de negócio, como restrições na construção, validações em métodos e interações com outras entidades. As entidades fornecem métodos para alterar o estado e só permitem alterações válidas de acordo com as regras de negócios.

Output (adapter/output)

Todo acesso a dados seja banco e/ou API’s expostos pelas interfaces do Use Case devem ser implementadas nessa camada. Na estrutura apresentada colocamos de exemplo três módulos, dois com implementações de repositório (um fake e um com a especificação JPA) para ilustrar a possibilidade de chaveamento entre as possíveis implementações, e um de acesso a clientes REST utilizando a especificação do Micro Profile REST Client. Assim com todo esse dinamismo podemos testar diferentes tipos de tecnologias (ex: Hibernate, JDBC, JOOQ) e implementações (ex: MySQL, SQLServer, Oracle) apenas chaveando os módulos na camada de aplicação.

O Fluxo

Para ilustrar a comunicação dos módulos e um pouco das responsabilidades de cada um, apresentaremos um fluxo de uma requisição GET de um objeto Location pelo seu id de registro. A requisição partirá do cliente indo até as camadas mais inferiores e retornando para o mesmo.

Exemplo de uma requisição GET

A requisição parte do usuário por um endpoint REST e entra em nosso módulo inputjaxrs-controller-v1 no qual converterá a requisição em um objeto do domain e chamará seu use-case específico. O use-case é apenas um orquestrador do domain , nesse caso específico ele validará se o locationId recebido é um legacy id e decidirá qual interface será utilizada para a consulta do registro — O use-case terá apenas interfaces de ‘saída’ e fica de responsabilidade exclusiva da camada de output implementá-la. Seguindo o fluxo, nosso módulo jpa-mysql-repository implementará a interface do use-case no qual ficará responsável em buscar as informações necessárias de sua fonte de dados e convertê-la em um objeto de domínio, assim, inicia-se todo o fluxo de volta até o usuário.

Testes

Definir o escopo e as responsabilidades dos testes foi algo muito importante para a construção dessa arquitetura. Com base na Pirâmide de Testes de Mike Cohn, projetamos os módulos para que atenda adequadamente a granularidade ideal. — Veja mais sobre testes nesse artigo: Definindo uma boa suíte de testes para seu Software

Em todos os módulos do projeto, decidimos aplicar apenas testes unitários com o JUnit5, AssertJ e testes de mutação com o PiTest. Dessa forma, garantimos a maior concentração em testes unitários, evitando o uso exorbitante de frameworks que fornece diversos recursos de testes integrados.

O AssertJ nos fornece verificações muito mais fluentes deixando todo o código de testes mais legíveis, já o PiTest com testes de mutação irá nos dar muito mais segurança na execução, criando diferentes casos para garantir que o teste escrito realmente esteja feito da melhor forma possível ele propaga automaticamente falhas em seu código e em seguida executa seus testes, dando um relatório no final sobre o percentual real de cobertura realizado.

Garantindo os testes na arquitetura

Em contrapartida, não podemos deixar de realizar testes integrados e/ou E2E, para isso, criamos um módulo aparte do projeto no qual não utiliza nenhuma dependência dos módulos principais.

Apenas com dependências de frameworks como Test Conteiner, REST Assured e Wire Mock o Acceptance Test nós da grandes poderes para gerenciar contêiner e executar chamadas em nossos endpoints com poderosos recursos de validações e mocks.

O Acceptance Test

Trouxemos o conceito do Acceptance Teste para execução de nossos testes integrados. Criamos um módulo totalmente isolado do projeto para garantir a independência de frameworks de desenvolvimento.

Utilizando Test Conteiner conseguimos em tempo de build subir uma imagem Docker do projeto e realizar nossos testes integrados, a imagem Docker é toda configurada no código pelo poderoso SDK do Test Conteiner, a ferramenta também nos proporciona integração com qualquer imagem Docker, conseguimos configurar um MySQL, Flyway, entre outros serviços necessários para execução de um teste completamente integrado, assim para cada teste integrado, subimos uma instância da aplicação totalmente pronta.

O REST Assured entra no modelo para realizarmos validações de contratos com seu Schema Validator e de fato a execução dos testes integrados. Dessa maneira, conseguimos ter segurança no contrato consumido e testado, e fluência na escrita dos testes.

Para ganhar velocidade, recurso e disponibilidade na execução dos testes dessa camada, utilizamos o Wire Mock para realizar os mocks de quaisquer chamadas a serviços externos de nossa aplicação, assim conseguimos configurar todo um ambiente fictício e testar de ponta a ponta nossa aplicação.

Nada impede dessa camada também realizar testes E2E, basta configurarmos nosso Test Conteiner com environments de ambientes reais, porém teremos muito cuidado com sua granularidade para que seja a menor possível.

Um ponto importante é a separação de execução desses testes, lembre-se de configurar o projeto para que haja uma diferenciação dos testes integrados e unitários, isso é muito importante para manter a agilidade no processo de desenvolvimento e refatoração.

Conclusão

A idéia do modelo é trabalharmos com especificações infindáveis que funcionem quase como um hot-swap para a camada de aplicação (frameworks), dessa forma, não só o domain fica isento de mudanças tecnológicas bruscas mas também as camadas externas, com isso, ganhamos ainda mais flexibilidade.

Com a rigidez que o conceito de testes de mutação nos traz, teremos segurança de um coverage real do software já com a ideia de termos apenas testes unitários em todos os módulos nos fornece a aplicabilidade da Pirâmide de Testes muito mais palpável. Ainda no cenário de testes, temos o isolamento dos testes de integração em um módulo exclusivo e devidamente configurado para executá-lo de forma independente dos unitários permitindo que ganhemos ainda mais agilidade no desenvolvimento e confiabilidade posteriormente em um build completo do sistema.

“Architecture is a hypothesis, that needs to be proven by implementation and measurement.”
— Tom Gilb

Mark Richards em ‘Fundamentals of Software Architecture: An Engineering Approach disse que um anti padrão comum é projetar uma arquitetura genérica que suporte todas as características da arquitetura e realmente cada problema tem sua particularidade. A ideia de nossa arquitetura não é generalizá-la para todos problemas da engenharia de software, mas sim mostrar que talvez possamos criar um modelo para soluções complexas que possuem grandes tendências de mudanças. Assim, esse modelo fortemente modular nos permite encaixar diferentes tipos de soluções com facilidade, sempre preservando as regras de negócio para mensurarmos novos recursos e tendências tecnológicas sem a necessidade de recriar todo um ecossistema complexo.

--

--