Design: Monolitos Modulares - Parte 2

William Santos - Apr 10 '23 - - Dev Community

Olá!

Este post é uma sequência de Design: Monolitos Modulares - Parte 1, e aqui falaremos sobre as diferentes estratégias de comunicação entre módulos.

Vamos lá!

Síncrono vs Assíncrono

Da mesma forma que ocorre em sistemas distribuídos, as duas opções estão disponíveis, e com as mesmas vantagens e desvantagens. Vamos explorar cada uma destas opções para dar clareza e, ao mesmo tempo, facilitar a decisão sobre qual e quando adotar.

Chamada Direta (Síncrona)

Este é o modelo mais simples de comunicação, e consiste basicamente em realizar chamadas de métodos de um módulo por outro. Confira o esquema na imagem abaixo:

Image description

Repare que, neste esquema, módulo de criação e envio de Ordens de compra e venda de ações consulta diretamente o módulo de Conta Corrente para averiguar se o saldo de um cliente permitiria uma compra, ou o módulo de Custódia (o repositório de ações de um cliente) para autorizar uma operação de venda. E, tão logo a operação de compra/venda seja autorizada, o módulo de ordens invoca o módulo de comunicação com a Bolsa para enviar a ordem. Ou seja, o módulo de Ordens tem conhecimento sobre os demais módulos envolvidos em sua operação, provavelmente injetando-os em sua construção.

Apesar de simples, este modelo é o menos indicado. Isso porque, como pode ser visto na imagem, ele gera um forte acoplamento em tempo de compilação entre os módulos. Neste cenário, em sua forma mais simples, faz-se necessária a injeção da API de um dado módulo para que outro possa invocá-lo.

Nota: aqui é importante notar que este comportamento é o mesmo para o caso de sistemas distribuídos onde um depende diretamente de outro para operar. Por isso a correta modelagem do domínio é fundamental. Em sistemas distribuídos essas chamadas de método seriam o equivalente a uma chamada RPC (como uma requisição HTTP ou gRPC).

Uma forma de evitar a injeção de um módulo em outro, entretanto, está disponível: o uso de orquestradores. A ideia é bastante simples: uma camada é criada acima dos módulos, de modo a permitir que uma informação residente no escopo de um módulo possa ser consultada e, então, passada para o módulo demandante. Veja o exemplo abaixo:

Image description

Neste caso, temos uma Façade (em inglês) que orquestra a chamada para os demais módulos a fim de promover o envio de uma ordem para a Bolsa.

Repare que, apesar desta camada extra impedir o acoplamento entre módulos em tempo de compilação, o mesmo ainda existe em tempo de execução (runtime). Por isso, recomenda-se cautela, também, no uso deste modelo. Além disso, caso o módulo demandante precise de uma informação completa sobre o estado do módulo provedor (como um cadastro de usuário ou de um dado produto), é possível que o escopo dos módulos precise ser revisto, de modo a reorganizar os contextos delimitados.

Por fim, esta abordagem é recomendada, apenas, em caso de necessidade de consistência estrita, ou seja, quando houver necessidade de garantir que todos os módulos compartilhem imediatamente do mesmo estado.

Nota: Para fins de governança do código-fonte, é importante que este orquestrador esteja sob a responsábilidade do módulo demandante, ou seja, aquele que precisa de uma informação residente em outro. Desta forma, caso a aplicação precise ser decomposta em serviços no futuro, este orquestrador passará a conter a lógica para invocar o serviço que detém esta informação.

Mensagens (Assíncrona)

Esta segunda abordagem é recomendada para os casos onde a consistência eventual seja uma possibilidade. Ou seja, quando o estado do sistema puder ser atualizado em algum momento do tempo após sua última operação.

A principal vantagem desta forma é o completo desacoplamento entre os módulos, já que os mesmos se comunicariam de forma indireta, a partir de eventos de integração (não confundir com eventos de domínio, que estão restritos a um mesmo contexto delimitado). Desta forma, após a emissão de uma mensagem por um módulo, todos os módulos interessados poderão consumí-la através de consumidores de mensagens, de modo a tornar possível a atualização de seu estado.

Nota: A fim de facilitar a governança do código-fonte, é recomendável que as mensagens que representam os eventos de integração pertençam, primariamente, ao módulo que as origina. Desta forma, em caso de decomposição, as aplicações que farão consumo dela podem negociar a forma de prover este contrato (em conformidade ou parceria). E, para evitar a necessidade de referenciar o pacote/assembly de um módulo em outro (que é também uma forma de acoplamento em tempo de compilação), é recomendado que cada módulo interessado no consumo de uma dada mensagem tenha uma cópia da mesma.

Existem duas formas de utilizar este modelo: dentro do processo e fora do processo. Vamos conhecer ambas nas seções abaixo.

Mensagens dentro do processo (in process)

Nesta forma de comunicação as mensagens são trocadas por meio de um barramento (message bus) residente na memória do processo do sistema, como demonstrado no exemplo abaixo:

Image description

Aqui temos o envio de eventos vindos da Bolsa, que devem promover uma atualização do estado de um ou mais módulos. No caso de uma ordem recebida pela Bolsa, o evento OrdemRecebida será enviado, e deverá atualizar o estado da ordem para refletir essa confirmação de recebimento (exatamente como o ack de um message broker). No caso de uma ordem executada, ou seja, caso um negócio tenha sido fechado, o evento OrdemExecutada é enviado e, no caso de uma compra, o saldo em Conta Corrente do cliente será reduzido e sua Custódia aumentada, e o inverso no caso de uma venda. Por fim, no caso de cancelamento de uma ordem pelo cliente, um evento do tipo OrdemCancelada será enviado, devendo atualizar o estado da ordem para indicar este cancelamento.

Pelo fato das mensagens trafegarem na memória do sistema, a depender de como esta é gerenciada, o tempo de espera entre a emissão da mensagem e a atualização dos demais módulos pode ser muito baixo, aproximando-se daquela observada quando utilizada uma chamada direta.

Entretanto, apesar de ser a forma mais simples de comunicação assíncrona, ela traz um custo alto: a probabilidade aumentada de perda de mensagens e, por consequência, inconsistência do estado do sistema. Caso alguma exceção ocorra durante a propagação das mensagens, as mesmas podem ser perdidas e o sistema terá seu estado corrompido.

Evidentemente, há formas de lidar com esta situação, utilizando padrões como o Outbox (em inglês), onde uma mensagem é persistida no schema do módulo que a origina e, em caso de sucesso em seu consumo, a mesma é marcada como processada.

Repare que, neste cenário, ao contrário do que ocorre com a chamada direta, existe um aumento significativo de complexidade, uma vez que um barramento precisa ser implementado, assim como contratos devem ser muito bem definidos para evitar o tráfego de eventos grandes (fat events) entre os módulos. Neste cenário uma correta modelagem do domínio é fundamental, tal como citado na Parte 1 desta série, na seção DDD.

Mensagens fora do processo (out-of-process)

Esta é considerada a forma mais segura de comunicação entre módulos por meio de eventos de integração. Nesta forma, um barramento de mensagens é instalado fora da aplicação, como um processo externo, para gerenciar o estado das mensagens. Este barramento pode ser o próprio banco de dados, com uma tabela de mensagens no schema do módulo produtor, ou um mecanismo de mensageria (como o RabbitMQ ou similares).

Aqui vemos mais um trade-off: segurança versus complexidade. A complexidade do uso de um mecanismo externo de gestão de mensagens é significativamente maior que a da chamada direta, por exemplo, por demandar não apenas o código necessário a interação com o mesmo como, também, pelo custo aumentado de operação, uma vez que este mecanismo demandará atenção e manutenção por si mesmo, além da aplicação.

Idempotência

Outro fator que aumenta a complexidade da comunicação assíncrona é a necessidade de se implementar controles de idempotência. Ou seja, uma mesma mensagem não deve poder alterar o estado do sistema por mais de uma vez. Para garantir esta necessidade também existem estratégias como, por exemplo, o armazenamento dos IDs dos eventos propagados, e a consulta pelo ID do evento recebido logo após o consumo da mensagem. Aqui o uso do padrão Outbox também é interessante.

Conclusão

É importante reparar que estamos diante de um cenário potencialmente idêntico ao de sistemas distribuídos. E este, apesar de ser um cenário mais complexo que o de um monolito tradicional, tende a tornar mais fácil a decomposição do sistema em serviços em um segundo momento, além de reduzir o acoplamento a ponto de evitar tanto A Grande Bola de Lama como, também, um potencial Monolito Distribuído no futuro.

Como sempre, estamos diante de um trade-off, entre acoplamento e complexidade. Por isso é importante conhecer o máximo possível das implicações sobre cada abordagem.

Sei que disse na Parte 1 desta série que traria uma aplicação de demonstração. Mas, por brevidade, optei por decompor este post em duas partes. Portanto, na Parte 3, traremos a aplicação de demonstração baseada nas imagens deste post, de modo a demonstrar tanto a comunicação síncrona (por chamada direta) quanto assíncrona (in process, por simplicidade).

Gostou? Me deixe saber pelos indicadores. Tem dúvidas ou sugestões? Deixe um comentário ou me procure pelas redes sociais.

Até a próxima!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .