Clean Architecture: Caso de Estudo em uma Aplicação Rails
Como desenvolvedor Ruby on Rails, eu e meus colegas, aqui na Todo Cartões, estamos sempre em movimento para encontrar melhores formas de produzir software de qualidade utilizando este framework. Recentemente, há uns quatro meses atrás, eu encontrei com um artigo muito interessante sobre o uso da aclamada clean architecture em conjunto com o Ruby on Rails. Naquele artigo, havia algumas demonstrações de como utilizar os conceitos apontados pela clean architecture dentro do framework. Lembrou-me que na faculdade já tinha cruzado caminho com os conceitos da clean architecture e da hexagonal architecture, o que me fez ver a excelente palestra do uncle Bob sobre o assunto. Isto animou-me, pois alguns pontos que são padrão em aplicações Ruby on Rails ou, simplesmente, acabam sendo lugar-comum, me incomodam um pouco.
No mesmo período em que me animava com estes conceitos, surgiu um novo projeto interno na Todo Cartões, o que era uma excelente oportunidade para tentar algo novo, no que se refere ao estudo arquitetural e Ruby on Rails. Conversei com nosso tech lead e nosso CTO, que aceitaram a proposta, mas com uma condição: tínhamos que encontrar um meio termo entre as propostas daquele artigo (que citei no início deste texto), os conceitos trazidos por uncle Bob e a forma padrão do Ruby on Rails. O desafio estava lançado.
Antes de dar início ao desenvolvimento, uma análise era necessária para entender bem a clean architecture. Aqui apresento um breve esboço no que está por trás deste conceito arquitetural.
A clean architecture, também conhecida como Onion Architecture, baseia-se no princípio da inversão de dependência (DIP), um conceito que vem da Orientação a Objetos e seus princípios, o SOLID. Além disso, também tem por base a separação clara entre o que é regra de negócio e o que é regra de implementação. Essencialmente, o DIP nos diz que as camadas de alto nível não devem depender de camadas de baixo nível. Ambas devem depender de abstrações. Isso significa que nossas representações de entidades de alto nível (classes que representam o domínio de negócio) não podem ser dependentes de entidades de baixo nível (classes que representam a infraestrutura/arquitetura). Deve haver uma intermediação entre elas. Esta proposta culmina em uma figura muito conhecida para quem entra neste assunto. Apresento-a logo abaixo:
A Figura 1 representa bem estes dois fundamentos. A dependência entre as camadas é unilateral: das camadas de baixo nível para as camadas de alto nível. Deixa evidente a separação que deve haver entre o domínio de negócio e o domínio da aplicação.
Este forma de estruturar a arquitetura de um software traz a vantagem do baixo acoplamento e alta coesão, na medida em que temos uma separação bem definida entre as regras, o que facilita trocas ou a adição de outros mecanismo de entrega, sem impactar as regras de negócio.
Encarando o Desafio
Diante dos fundamentos da clean architecture e da premissa de não alterar demais o padrão Rails de desenvolvimento, deu-se o início do projeto.
Já no começo tivemos que tomar uma decisão difícil: Ruby on Rails utiliza-se do design pattern Activerecord, que é incompatível com um dos fundamentos de nossa escolha arquitetural. Normalmente, para casos como este, de uso de um ORM (Object Relational Mapper) e a clean architecture, se faz uma duplicação das entidades, onde a ORM é usada como uma representação da infraestrutura de dados, a entidade de negócio passa a ser um POJO (PORO, no mundo Ruby =p) para representar a informação e passa-se a utilizar o Repository Pattern como intermediário entre as duas entidades. Confuso?! Nós também achamos! Pensamos que isto iria trazer muitos boilerplates para dentro do projeto. Além disso, perderíamos o poder da Activerecord e iria bem contra uma das premissas de mexer no Ruby on Rails o menos possível. A decisão foi manter a estrutura padrão do Rails neste caso.
Passado este ponto, rumamos para trazer para dentro do projeto a intenção de deixar clara a separação das regras que de fato eram próprias do negócio e o que era parte da aplicação e do mecanismo de entrega. Fomos então apresentados ao conceito dos Casos de Uso (Use Case). Aqui, preferimos adotar o nome Interactors, pois é um termo que conceitualmente achamos mais apropriado para o fim ao qual seriam utilizados, o de permitir a interação das entidades de negócio. Como assim?
Nossa intenção foi desenhar uma solução arquitetural que isolasse as entidades de negócio, uma das outras, permitindo pouca interação direta entre elas. A ideia era reduzir o acoplamento entre as classes na camada de modelos e evitar o “canivete suíço” em que elas podem se tornar em uma aplicação Ruby on Rails. Para que elas interagissem de forma a prover o software com as regras de negócio sob as quais as entidades deveriam interagir, criamos esta segunda camada: Interactors.
Lembrando que estamos usando a Activerecord, portanto, o banco de dados vem junto. Bem, então aqui temos a definição de nossas entidades de negócio (o que são) e as regras de interação entre elas (como devem comportar-se entre si). Isso encerra nosso domínio de negócio.
Neste ponto, tivemos que iniciar o planejamento de como o nosso domínio iria receber os dados para aplicar as regras de negócio e como ele devolveria estas informações. A esta nova camada dá-se o nome de mecanismo de entrega. No contexto da Todo Cartões, utilizamo-nos de uma arquitetura distribuída, que segue o protocolo HTTP para comunicação, sendo que esta, muitas vezes, ocorre de forma assíncrona. Isso torna o escopo do mecanismo de entrega um pouco maior, pois a nossa “porta de entrada” não será nossa “porta de saída”. Como o processamento dos dados de entrada devia ser assíncrono, tínhamos claramente uma camada para este tipo de job. Esta camada deveria então servir o mecanismo de entrega, acionar as regras corretas do domínio, e o resultado deveria ser enviado para algum outro lugar, via protocolo HTTP.
Como resultado, tivemos a seguinte estrutura de camadas (lembrando que na figura as setas representam a dependência entre as camadas):
A Figura 3 representa como ficaram estruturadas as nossas camadas. Perceba que temos como camada de entrada as nossas controllers, que encontram-se na camada de mecanismo de entrega. As controllers agendam um worker e passam os dados informados nos endpoints como parâmetros para os workers. Estes, por sua vez, possuem o mínimo de lógica para decidir qual interactor deve ser executado para obter o processamento dos dados recebidos conforme as regras de negócio. Caso haja a necessidade, o worker pode pegar os resultados com origem em um interactor e fazer consultas a APIs externas, ou mesmo, utilizar este mecanismo como saída dos dados, quando estes já tiverem tido seu processamento finalizado.
A Figura 3 finaliza o que de fato foi implementado para este projeto, que foi finalizado recentemente e com bons resultados.
Resultados
Após o encerramento do desenvolvimento do projeto podemos observar alguns resultados, pontos positivos e pontos negativos desta abordagem.
Pontos Positivos
Baixo Acoplamento/Alta Coesão: ao finalizar o projeto, houve a execução de uma ferramenta de análise estática sobre o código, com o intuito de identificar quais foram os ganhos para o código após essa abordagem, em especial para a manutenibilidade. Observe o gráfico abaixo:
A Figura 4, em uma análise rápida, já aponta algo bem interessante: a classe mais complexa que há no projeto possui um valor um pouco maior que 150 pontos. Os demais projetos de tamanho similar a este, dentro da empresa e que se utilizam do caminho padrão do Ruby on Rails, possuem pelo menos o dobro desta complexidade. Outro ponto relevante é o número de classes e a sua concentração abaixo da linha de 100 pontos de complexidade, sendo uma evidência de classes que executam um único propósito e com pouca referência às demais. Outra medida interessante foi o churn, que indica a quantidade de mudanças ocorridas em uma classe. Há também uma concentração grande de classes com um churn pequeno, o que aponta também um baixo acoplamento (em sistemas com grande acoplamento, mudanças normalmente desencadeiam um efeito dominó, o que parece não ocorrer aqui).
Código mais testável: observou-se maior facilidade na criação de testes unitários. Isso se deve em especial a alto coesão conquistada pela arquitetura. Há um isolamento entre as classes e uma identificação rápida e clara do que cada classe deve fazer e, portanto, do que se deve testar nela. Por exemplo, temos um interactor responsável por criar um determinado tipo de entidade. Ele que conhece as regras de criação para aquela entidade. Fazer os testes de criação desta entidade tornou-se uma tarefa clara e objetiva, na medida em que todas as regras para esta operação estavam bem isoladas e em um único local. Obtivemos êxito no desenvolvimento dos testes unitários, alcançando 100% de cobertura. A separação das camadas de aplicação e negócio fica clara também pelos testes unitários: testes de controller efetivamente não precisaram verificar se regra X ou Y havia sido executada. Analisava apenas se os dados corretos entravam e se o worker era agendado (evidentemente que os casos de falha também estão cobertos).
Isolamento de Regras: como descrito no item 2, houve efetiva separação entre o que é regra de aplicação e o que é regra de negócio. Isso permite grande facilidade de navegação no código, pois sabe-se exatamente onde estão as regras, ou em interactors ou nas models, sendo regras de interação no primeiro grupo e regras de validação e relacionamento nestas últimas. Esse isolamento permite possíveis mudanças, por exemplo, de mecanismos de entrega, de forma rápida. A máquina que carrega as regras de negócio isolada, basta chamar o worker (quando assíncrono) ou o interator (quando síncrono) dentro do novo mecanismo de entrega que as regras por trás serão mantidas iguais.
Pontos Negativos
Mudança na forma padrão de pensar Rails: este ponto já era esperado. De alguma forma há impacto em como pensar na solução, mas isto é resolvido com uma boa documentação, pair programming e conversas com a equipe, para que todos estejam alinhados.
Custa mais caro a implementação: a mudança de abordagem arquitetural ocasionou um efeito colateral na forma de desenvolver uma aplicação Ruby on Rails. A tomada de decisões ficou demorada. Normalmente, ao desenvolver em Rails, o caminho a se seguir é claro para qualquer pessoa familiarizada com o framework, pois há um padrão já seguido. Ao mexermos neste padrão, mesmo tentando minimizar este mudança, senti que tomar uma decisão exigia mais tempo, pois era necessário pensar em regras arquiteturais auto impostas ao projeto. Novamente, já era um custo esperado, mas que se paga pelas vantagens acima listadas.
Conclusão
Obteve-se sucesso em fazer uso da clean architecture sem ser muito intrusivo na estrutura do Ruby on Rails.
Portanto, é possível aliar os conceitos da clean architecture com o framework, embora haja perdas tanto de um lado quanto de outro. O que se pode observar é que esta abordagem funcionou bem para um projeto de médio porte, mas não é aconselhável para projetos de pequeno porte, em que acredito ser melhor manter a estrutura padrão do Ruby on Rails, e nem para projeto de grande porte, onde, acredito, os pontos negativos aqui apontados devem ganhar maiores proporções e se sobressair em relação pontos positivos, o que tornaria o uso da abordagem desfavorável. Em projetos de grande porte em que se queira utilizar-se da clean architecture, é provável que seja melhor abrir mão da estrutura do Ruby on Rails, buscando o isolamento da camada de domínio em uma gem ou no uso de engines.