Arquitetura de Software em JavaScript — Ports, Interfaces e Interactors

Rumo a uma arquitetura de software mais limpa

Image for post
Image for post
Uncle Bob está de olho em você!

Algumas vezes, o código que escrevemos é acoplado ao framework que usamos. Isso torna nosso código mais difícil de testar, reusar e ter 100% de razão do porque daquela abordagem.

Vamos ver um exemplo, nós vamos usar Express como o nosso framework:

Vamos supor que estamos montando um game quiz, onde os usuários podem submeter as respostas para uma questão. A resposta irá ser submetida por HTTP POST, toda a informação será gravada em um banco de dados MongoDB.

  • Se a resposta for correta, nós iremos premiar o usuário com um ponto
  • Se o usuário terminar de responder todas as questões, ou seja, finalizou o game, nós queremos mandar um email parabenizando-o
  • Se a resposta não for correta, nós iremos dizer ao usuário para tentar de novo

Abaixo,vamos ver um exemplo de implementar isso usando Express:

No exemplo acima, nós separamos o código back-end em múltiplos serviços. Cada serviço tem uma responsabilidade do seu próprio domínio. Podemos ver: AnswerService, EmailService, PointsService, e QuestionService.

O código acima é bem direto, certo? E nós separamos todos os serviços baseado nos interesses de domínio.

E se eu te disser, que ainda não está limpo?

O código acima, que deveria descrever a lógica de negócio, também contém muito detalhes de implementação, que são irrelevantes para os casos de uso e regras de negócio do seu projeto, tais como:

  • De onde os dados estão sendo enviados (req.user, req.params, req.body)
  • De onde buscar os dados necessários (QuestionService, AnswerService, …)
  • Como premiar o usuário (PointsService)
  • Como enviar uma resposta ao usuário (res.render, EmailService)
  • O template HTML usado (answer-correct, answer-incorrect)
  • Como determinar se uma resposta está correta (.trim().toLowerCase())

Nesse exemplo, as regras de uso e de negócios do seu projeto estão acopladas com seu framework, resultando no seguinte diagrama:

Image for post
Image for post
Sua regra de negócio está diretamente ligada ao seu framework

Vamos testar o exemplo acima. Como nossa regra de negócio está acoplada com as tecnologias escolhidas, não podemos testar apenas uma unidade. Nós precisamos testar a unidade e tudo que está enrolado nela.

No final, acabamos criando stubs para várias coisas:

E quer saber? Não tem muito acontecendo nesse teste. Ao invés de testar nossa regra de negócio, nós estamos testando várias outras coisas, como:

  • O router do Express
  • O modo que ele cuida dos requests
  • O modo que ele envia uma resposta
  • A código de resposta e o seu conteúdo

Imagine se nós tivéssemos mais 10 testes similares a esse, testando outras partes da sua lógica de negócio.

A sua suíte de testes acaba ficando verbosamente repetitiva.

Como se não pudesse piorar…

Para falar a verdade, o exemplo acima é bem generoso. Das bases de código que já vi — e até mesmo escrevi — são totalmente acopladas ao modelo ORM escolhido — algo como:

Image for post
Image for post
Agora você tem mais uma camada acoplada na sua lógica de negócio! Não soa nada bom…

E com certeza tem outras camadas de serviços acopladas a lógica, tais como Mailgun e Redis (mas vou poupar os diagramas, acho que você entendeu!)

Nesse caso, é bem mais difícil criar stubs, porque existe múltiplas maneiras de consultar o banco de dados e atualizar uma informação.

Nós precisamos criar mocks e stubs para os métodos corretos. Se eu quiser mudar meu código de req.user.save() para UserModel.findOneAndUpdate(), todos os meus testes irão falhar.

Criar stub para coisas como essa não é muito diferente de escrever o mesmo código duas vezes. Nesse ponto, parece mais fácil abandonar o teste unitário e ir logo para o de integração.

E, realmente parece uma boa idéia. Irá nos salvar boas linhas de código, principalmente de stub. Em adição, nós também podemos verificar se o sistema como um todo funciona, usando apenas uma suíte de teste. Mas agora, nós iremos precisar de um banco de dados para teste e fixtures — tudo isso apenas para testar a lógica de negócio.

E o negócio continua a crescer…

Conforme nossa regra de negócio continua a ficar mais complicada, nossos testes começam a ficar mais lentos. Por exemplo, no início, criar uma questão era uma simples tarefa de colocar um novo documento no nosso banco de dados MongoDB. Bem rápido, né?

Mas nosso app cresceu, agora precisamos adicionar notifições, renderizar e mandar emails, atualizar estatísticas, colocar novas questões no feed de cada seguidor, e reindexar nosso mecanismo de busca.

Nossos testes começam a ficar tão lentos que o sentimento de que eles estão nos causando lentidão (ou tornando o time menos produtivos), começa a crescer.

E chega a hora de escalar…

Nosso game ficou popular, e agora nós temos muitos usuários, nosso único servidor não aguenta mais!

Nós decidimos que devemos usar microservices.

Agora, os testes de lógica de negócio que dependiam das fixtures do banco de dados MongoDB não irão funcionar mais, porque cada microservices tem seu próprio banco de dados. Eles podem até ser escritos por diferentes times em diferentes linguagens.

Portanto, boa parte do código em produção e testes precisam ser reescritos.

Nós precisamos voltar ao métodos que criamos stub, porque, rodar todos esses bancos de dados e microservices, só para testar uma linha de código que mudou, simplesmente não é prático.

Algumas vezes, precisamos usar condicionais if para disabilitar alguns pedaços do código em produção durante o teste, só para ir mais rápido.

Isso é caminho para um código mais complicado, o que o torna mais propenso a erros.

Tecnologias mudam…

E se,ao invés de HTTP, nós precisamos ir real-time e queremos usar WebSockets, para ficar a frente da concorrência? E se quisermos suportar ambos?

E se, também quisermos oferecer uma API REST, para que nosso game possa ser integrado com outro SaaS?

Para uma aplicação em tempo real, que tal usarmos algo como Firebase ou RethinkDB, que suportam consultas em tempo real, por padrão, ao invés do MongoDB?

Quão rápido nós podemos nos mexer?

Quando regras de negócio e implementação estão acopladas, elas precisam ser reescritas toda vez que precisamos mudar algum detalhe de implementação.

Quando mudamos o detalhe da implementação, se não formos cuidadosos, é bem fácil introduzir bugs relacionados a regra de negócio — especialmente quando nossos servidores estão pedindo água e precisamos nos mexer mais rápido.

Uma arquitetura mais limpa

Tio Bob descreveu que uma arquitetura limpa é uma arquitetura onde “as regras de negócio podem ser testadas sem a interface, banco de dados, servidor web, ou qualquer elemento externo”.

Se você é novo ao conceito de Arquitetura Limpa, eu realmente recomendo você assistir essa apresentação:

Você pode ficar surpreso com minha próxima afirmação, mas é a verdade:

Em JavaScript, é surpreendentemente fácil aplicar esses conceitos

E é exatamente por isso que eu decidi escrever esse artigo. A abordagem que irei utilizar, é baseada na Arquitetura Hexagonal.

Primeiramente, vou te mostrar o código desejável que descreve o nosso caso de uso em JavaScript. Não se preocupe de onde a informação vem, ou para onde ela vai. Para mim, seria algo como:

O código acima descreve a regra de negócio com o mínimo possível de detalhes de implementação. Nós estamos assumindo que, de algum modo, essas funções existem.

Existe um pequeno detalhe aqui, boa parte dessas funções irão acessar informações que irão realizar operações I/O. Que são inerentemente assíncronos em Node.js.

Esse é o único motivo para usar await no exemplo acima. Algumas pessoas preferem ES6 Generators, que disponibilizam uma maior flexibilidade.

Conhecendo Port

Se você rodar essa função, você irá receber vários ReferenceErrors, porque nós não especificamos de onde essas funções estão vindo.

Ao invés de deixar para submitAnswer encontrar essas funções (deixando a função acoplada a implementação), nós iremos providenciar elas para submitAnswer, através de uma mínima e bem definida interface chamada de Port.

O que acabamos de escrever aqui é um interactor registrando um caso de uso. É uma função que representa a regra de negócio desse caso de uso. Ele não tem conhecimento de nada além das regras de negócio que essa função é responsável.

Todas as interações com o mundo exterior são realizadas através do port, que declara todas as funções necessárias para esse caso de uso.

Criando uma clara fronteira entre as regras de negócio e os detalhes de implementação.

E o melhor de tudo isso, é quando vamos testar esse caso de uso. Podemos simplesmente passar os valores necessários através do port para o interactor, e assertando o resultado que foi retornado:

Sua regra de negócio não mais depende do framework. Está totalmente desacoplada de como as informações são salvas, ou quais informações são enviadas ao usuário. Nossos testes de caso de uso são extremamente rápidos!

Ao invés de usar um framework como base, eles são usados como ferramentas para nos ajudar a realizar coisas mais rápido.

Ao inverter as dependências e segregar as interfaces, fica muito mais simples de mudar os detalhes de implementação (ex: disponibilizar um endpoint REST, ou ir real-time com WebSockets), sem precisar tocar o nosso caso de uso.

Se você chegou até aqui, quero te dizer que, agora é o momento de reescrever nossa aplicação Express!

O mecanismo de entrega

Agora que nós temos um sólido e bem testado interactor com caso de uso. Vamos voltar ao nosso exemplo usando Express, e aplicar todo o conhecimento que vimos nas seções anteriores:

Nós enviamos um adapter para nosso interactor, que contém os detalhes das integrações necessárias para essa função funcionar dentro do Express.

Image for post
Image for post

Como você pode ver, não tem mais lógica de negócio dentro da nossa aplicação Express. Sem condicionais. Nós estamos simplesmente injetando funções, cada uma realiza uma coisa, através do port, dentro do interactor.

Para mim, isso é o suficiente. Nós separamos os limites da nossa aplicação entre core e shell. O shell contém os detalhes necessários de funcionamento e geralmente não contém nenhuma lógica. Todas as regras de negócio vivem dentro do core, que não depende de nenhum framework ou serviços externos.

Eu também testaria algumas integrações e detalhes do sistema do nosso shell, só para verificar que todos os detalhes estão no lugar correto.

E algumas vezes, eu nem tenho testes automatizados para o shell! :)

Em um dos meus projetos pessoais, eu sempre testo o shell manualmente, e nunca encontrei grandes problemas. Isso porque, arrumar problemas de detalhes de integração é muito, muito mais fácil que arrumar problemas de lógica de negócio.

Talvez algumas pessoas queiram testar os adapters usando nesse caso de uso. Provavelmente há algum valor em testar isso, mas, na minha humilde opinião, os benefícios não valem o custo.

Múltiplos Port’s

Vamos olhar a interface do nosso port para submitAnswer:

export async function submitAnswer ({
answer,
question,
currentUser,
isAnswerToQuestionCorrect,
awardPoints,
recordCorrectAnswerToQuestion,
hasUserAnsweredAllQuestions,
sendCongratulationsEmailToUser,
respondWithCorrectAnswer,
respondWithIncorrectAnswer
})

Não é simples fazer testes unitários para um adapter de um port que realiza tantas coisas como essa. Alias, isso também fica difícil de re-usar.

Por exemplo, se eu tiver que disponibilizar endereços em REST API e WebSockets para o nosso caso de uso. Muito desses campos iriam continuar os mesmos (ex: isAnswerToQuestionCorrect), equanto outros mudariam (ex: respondWithCorrectAnswer).

Nesse caso, eu acredito que deveríamos separa-los em múltiplos ports. Aqui uma idéia:

export async function submitAnswer ({
request: {
answer,
question,
currentUser
},
data: {
isAnswerToQuestionCorrect,
awardPoints,
recordCorrectAnswerToQuestion,
hasUserAnsweredAllQuestions
},
notification: {
sendCongratulationsEmailToUser
},
response: {
respondWithCorrectAnswer,
respondWithIncorrectAnswer
}
})

Agora podemos testar cada port separadamente:

E, extraindo todos os ports do nosso exemplo em Express:

Que diferença, ein!?

Uma arquitetura mínima e limpa!

Para expor nossos casos de uso para WebSocket, a única coisa que precisamos substituir são os ports de request e response. Os ports de data e notification serão os mesmo :)

Não se esqueça da carga cognitiva!

Dica importante: Quanto mais ports você tiver, mais código você terá, significando mais abstrações, ligações, detalhes e complexidade. O código é transformado em pequenos pedaços espalhados. Isso requer uma atenção e capacidade de guarda um modelo mental maior.

Eu sempre começo com um port no início do projeto, e vou dividindo conforme eu realmente preciso. Geralmente é quando preciso utilizar o mesmo interactor em diferentes detalhes de implementação. Ao chegar nessa situação, leva em torno de 10 minutos para dividir e atualizar a base de código.

Começar com múltiplos ports significa criar, desnecessáriamente, um carregamento cognitivo maior ao ler o código. Devemos evitar isso!

Sendo assim, eu recomendo que você comece simples e use apenas um port no início, e só dividi-lo quando realmente precisar.

Para finalizar

É isso aí galera!

Um exemplo de uma arquitetura limpa em JavaScript.

Espero que você tenha extraído algo desse post e fique à vontade para compartilhar seus pensamentos e experiências abaixo!

Obrigado por ler!

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store