Modular Monoliths – Parte 1 | A história por trás da decisão

1 – O contexto

O ano era 2018. A Beep tinha pouco mais de 50 colaboradores e crescia de maneira acelerada. A equipe de tecnologia era formada por 5 desenvolvedores, que estavam desde a concepção do negócio. Como qualquer startup em sua fase inicial, boa parte da base de código foi criada em torno de muita experimentação. Antes de encontrar o tão sonhado product market fit, o negócio mudava em questão de meses ou, até mesmo, em poucos dias.  O resultado desse processo foi ter um legado sem muita sofisticação no quesito design. Além disso, em determinado momento, esse código começou a afetar nossa velocidade de mudança, principalmente por conta do acoplamento exagerado que existia. Depois de uma série de tentativas, o negócio parecia ter encontrado, finalmente, seu product market fit ideal e começava crescer ainda mais rápido. Como a maioria da operação da Beep é suportada por tecnologia, precisávamos nos adaptar ao novo cenário. Fizemos um levantamento dos problemas que estávamos enfrentando inicialmente e chegamos a uma lista parecida com essa:
  • Dificuldade de mudança – se alterávamos uma parte do código, quebrávamos a outra; 
  • Dificuldade de adicionar novos membros no time – novos membros teriam de conhecer a base como um todo;
  • Número de bugs aumentando devido às mudanças rápidas que precisavam acontecer.
A resposta que encontramos para esse cenário, em 2018, parecia óbvia – afinal, era o hype da época: dividir nossa base de código em partes menores e publicá-las separadamente. Isso reduziria o acoplamento entre as partes e nós poderíamos controlar melhor as mudanças. Resumidamente, a ideia era distribuir nossa aplicação em uma arquitetura baseada em microservices. Isso pareceu uma ótima ideia inicialmente, então resolvemos começar a implementação dessa arquitetura, por meio de um componente menor, com menos riscos, onde poderíamos experimentar e aprender de maneira mais controlada. Escolhemos implementar toda a lógica de gestão de equipes de atendimento em um componente separado. A gestão das equipes é um elemento central do nosso negócio e está diretamente relacionada com a quantidade de horários disponíveis para agendamento no aplicativo. Ou seja, para conseguirmos vender, precisamos ter equipes disponíveis.  

2 – Microservices na Beep

2.1 – Arquitetura inicial

Antes de implementarmos microservices na Beep, contávamos, basicamente, com dois grandes sistemas: 
  • Agendamento, responsável, principalmente, pelas vendas e acompanhamento dos atendimentos diários; 
  • Operação, chamado carinhosamente de Oswaldo pelo nosso público interno. 
O Oswaldo é responsável por toda a gestão da nossa operação, desde definir quais são as áreas de atendimento onde atuamos até o controle de estoque das vacinas que aplicamos. O diagrama abaixo ilustra uma versão mais simplificada da nossa arquitetura nessa época:     As plataformas de venda se comunicam com o nosso sistema de agendamento por meio de uma Http API. Esse sistema é responsável por validar se data e horário estão disponíveis e se é possível comprar o produto desejado. Além disso, ele confirma o pagamento e publica uma série de eventos para outros subsistemas interessados. O mais importante deles é o Oswaldo, que, por sua vez, registra esse agendamento e o disponibiliza para as equipes de Imunizações, Operações e Central de Relacionamento.

2.2 – Extraindo o primeiro Microservice

A gestão das equipes de atendimento era feita diretamente no Oswaldo e foi justamente esse o primeiro componente a ser extraído. Depois dessa alteração, o desenho da arquitetura ficou assim:     Resultado: o diagrama ficou ligeiramente mais complicado que o anterior. Agora, temos um conjunto mais amplo de relações entre os sistemas que precisamos monitorar. Essas relações estão representadas pelas setas, que são divididas em sólidas – indicam uma comunicação síncrona entre os componentes – e tracejadas – indicam uma comunicação assíncrona. Boa parte da comunicação entre esses componentes é feita de forma assíncrona, publicando eventos diretamente em um broker de mensagens, que, no nosso caso, é o RabbitMQ. Durante a implementação desse primeiro componente, já notamos algumas dificuldades que não tínhamos no modelo anterior. Isso ficou mais evidente, inicialmente, na parte da infraestrutura. Vários questionamentos tiveram de ser respondidos quando resolvemos separar o código desse componente. Alguns deles não faziam sentido antes da separação, então, tivemos que lidar com algumas situações praticamente do zero:
  • Onde será feito o deploy desse componente? Uma instância nova EC2?
  • Como será feito o deploy? Vamos utilizar Containers? ECS? Kubernetes?
  • As APIs que estão expostas precisam de segurança?
  • Precisamos lidar com o versionamento dessas APIs, mesmo para uma aplicação interna nossa?
  • Como lidar com as transações que eventualmente podem ser distribuídas?
  • Como implementar o monitoramento dessa aplicação? Como monitorar traces distribuídos em várias aplicações?
Repare que todas essas perguntas são técnicas. Nenhuma resolve, de fato, o problema de negócio que esse componente tem como responsabilidade: gerir a disponibilidade das equipes nas agendas de atendimento. A literatura já alertava sobre essas adversidades na distribuição de objetos há, pelo menos, 15 anos. Martin Fowler, em seu livro Patterns of Enterprise Applications, descreveu o que a comunidade chamou de “primeira lei dos objetos distribuídos”:

Don’t distribute your objects.

Apesar de já termos alguma experiência com objetos distribuídos e saber que esses obstáculos eram comuns, resolvemos a maioria deles, atrasamos a decisão de outros e optamos por seguir com a estratégia. Uma vez que esse microservice já estava no ar, começamos a colher alguns frutos interessantes: 
  • A lógica de disponibilidade, agora, estava totalmente isolada;
  • Adicionar ou alterar alguma funcionalidade seria possível com o conhecimento somente daquele domínio;
  • Era mais fácil ter uma cobertura de testes, pois a aplicação foi construída de maneira totalmente isolada do framework.
O resultado foi bom e resolvemos extrair um segundo componente.

2.3 – Extraindo a gestão de estoques para marketplace

Com os bons resultados do primeiro microservice, resolvemos seguir com a extração de mais um componente, que, em teoria, seria de baixo risco e isolado. Naquela época, a Beep tinha uma modalidade de vendas baseada em marketplace: uma clínica poderia publicar sua agenda em nosso aplicativo. Isso permitia que o cliente agendasse sua vacinação e fosse até uma clínica tomar a vacina. Um problema muito importante, que precisava ser resolvido nessa modalidade de vendas, era quantificar as vacinas que poderíamos vender. Ou seja, de alguma forma, era necessário que controlássemos o estoque das clínicas disponíveis no marketplace. Assim como a gestão de equipes, a gestão de estoques do marketplace também era responsabilidade do Oswaldo. Identificamos e deixamos explícito o Bounded Context, que cuidava do controle desse estoque, e extraímos para um novo microservice. O desenho da arquitetura ficou assim:     Com a introdução do novo componente, podemos observar um aumento nas relações entre eles, com mais linhas sólidas e tracejadas aparecendo. Isso, no fim das contas, se traduz em complexidade. Esse componente, em especial, tinha uma característica muito mais transacional que o anterior: para toda venda que acontecia no marketplace, um evento era lançado e o componente era responsável por tratar aquele evento, para que, em um determinado momento, algum item fosse removido do estoque. O problema de controle de estoque já tem seus desafios habituais, então lidar com ele em um cenário distribuído começou a ficar ainda mais desafiador.  As transações distribuídas ganharam nossa atenção. Antes, todo o processo de atualização da saída de produtos era feito no mesmo sistema de agendamento. Isso facilitava muito porque, caso algo desse errado, um rollback na transação era suficiente para solucionar o problema. Agora, com as responsabilidades divididas entre 3 componentes distribuídos, o rollback não era mais tão simples assim. Se um dos componentes falhar, dependendo de onde essa falha acontecer, desfazer esse processo pode ser muito trabalhoso. Imagine o exemplo:
  1. Cliente seleciona 3 vacinas diferentes e confirma o agendamento
  2. O sistema de agendamento inicia as validações do processo para confirmar o agendamento
    1. É verificado se a data ainda está disponível
    2. Confirma-se o pagamento
    3. Os eventos de agendamento confirmado são lançados
  3. Os sistemas interessados (inclusive o componente de estoque) reagem aos eventos e executam suas tarefas
Uma vez que o processo de agendamento é baseado em eventos assíncronos, se acontecer algo de errado na baixa do estoque, o que fazemos? A solução para esse tipo de problema é bem documentada na literatura, desde o uso de Two-phase commit até soluções mais sofisticadas com Sagas. O ponto aqui não é como resolver esse caso, mas o fato dessas situações estarem acontecendo apenas por conta da solução de arquitetura que escolhemos. Isso não tem uma relação direta com o problema de negócio que estamos tentando resolver: controlar a quantidade de vacinas disponíveis no marketplace.

2.4 – A realidade

Com a implementação desses dois componentes, aprendemos rapidamente que existe uma série de desafios para uma implementação bem sucedida de microservices. Alguns dos problemas que percebemos além dos que listamos aqui:
  • Monitoramento é fundamental para entender o que está acontecendo em cada componente;
  • Logs passam a ser um problema distribuído também – e você precisa deles; 
  • Debugar um problema que passa por vários componentes – que podem executar de maneira assíncrona – não é uma tarefa trivial;
  • Ter alguém dedicado a tarefas de infraestrutura, um DevOps ou alguém que faça esse papel é muito importante para o sucesso da implementação.
Percebemos que, para o nosso contexto específico da Beep (naquela época em que a equipe tinha 5 desenvolvedores, sem nenhum DevOps e com o negócio mudando rápido), o mais importante era resolver os problemas do negócio e não ficar travado por problemas técnicos, que drenavam nossa velocidade de entrega.  

3 – A solução proposta

Em um determinado momento dessa jornada, nos deparamos com o seguinte tweet do Simon Brown:   Isso pareceu fazer muito sentido e fez nosso time refletir fortemente sobre quais eram as motivações que nos levaram a distribuir nossa aplicação. Durante essa reflexão, percebemos que o mais complicado era a maneira como estávamos desenvolvendo nosso código dentro do monolito. Todos os problemas pareciam se encontrar em um problema maior, que era o acoplamento exagerado entre as partes. Percebemos que estávamos nos deixando guiar pelo framework e não tomando os devidos cuidados com o design.

3.1 – Voltar pro monolito?

Naquele momento, nós já tínhamos entendido que continuar extraindo os componentes não estava ajudando muito. Quer dizer, até ajudava, mas, no fim das contas, estava mais complicado do que antes. Então, resolvemos procurar alternativas para tentar melhorar nosso design dentro dos nossos monolitos. A equipe já tinha uma boa experiência com design orientado a objetos e conhecia bem as práticas de Domain Driven Design, só não estávamos aplicando aquilo de maneira organizada. A partir daí, resolvemos tentar entender como poderíamos tirar vantagem de uma abordagem de design para conseguir evoluir e manter o código dentro da mesma base de código. Pesquisando um pouco mais sobre o que o Simon Brown estava querendo dizer naquele tweet, chegamos a uma proposta bem interessante, que ele chamou de Modular Monoliths.

3.2 – Modular Monoliths

A ideia básica por trás do conceito de monolito modular vem da organização e separação da sua aplicação em módulos ou componentes autônomos, que se relacionam entre si, mas estão dentro de uma mesma base de código. Normalmente, as decisões sobre os estilos arquiteturais que podemos usar variam entre estas extremidades:     Nesse cenário, de um lado vemos um monolito tradicional, com todo o código na mesma base, mas sem necessariamente estar separado e organizado. Do outro lado, vemos um conjunto de componentes distribuídos. Diferentemente de um monolito tradicional, o monolito modular preza por ter os componentes explícitos, seguindo os mesmo princípios de modularização que são usados para distribuir.   No exemplo acima, é possível observar que, dentro da mesma aplicação, o código está separado, se comunicando por interfaces bem definidas, de maneira que seja possível ter um controle mais detalhado do acoplamento entre eles, podendo até ter bases de dados diferentes para diferentes módulos. Usando o primeiro diagrama como exemplo, os monolitos modulares são uma solução intermediária entre a base de código única e os componentes distribuídos.  
Livre adaptação do diagrama de Simon Brown – http://www.codingthearchitecture.com/2014/07/06/distributed_big_balls_of_mud.html
  Esse diagrama mostra que podemos ter uma solução intermediária entre as duas extremidades. Foi então que, em 2019, resolvemos parar de distribuir nossas aplicações e implementar os monolitos modulares nas nossas bases de código. Isso tem nos ajudado a focar mais no problema de negócio, evitando, assim, os problemas listados, que estão muito mais relacionados com tecnologia. Nas próximas partes deste artigo, vamos detalhar o processo por trás da implementação desse tipo de solução, explorar em detalhes como são organizados e quais técnicas nos baseamos para a sua implementação. Até lá!