Por que a maioria dos microsserviços deveria ter sido módulo
A fronteira de rede é um imposto permanente. Quatro razões para um serviço merecê-la, e os sinais de que o seu deveria ser módulo.
Já revisei sistemas suficientes para dizer isso sem rodeios: a maioria dos microsserviços que encontro são módulos que ganharam um endereço de rede cedo demais. O time pagou o preço inteiro da distribuição e recebeu quase nenhum dos benefícios.
O que uma fronteira de rede custa de verdade
Quando uma chamada de função vira uma chamada HTTP ou gRPC, cinco coisas mudam de uma vez.
Latência: uma chamada em processo leva nanossegundos; uma chamada pela rede leva um milissegundo em um dia bom, e um request que passa por seis serviços paga isso seis vezes, em série. Falha parcial: uma função retorna ou lança exceção. Uma chamada remota pode dar timeout depois de fazer o trabalho, e você fica sem saber se aconteceu. Versionamento: você não consegue mais mudar uma assinatura e corrigir todos os chamadores em um commit; o formato antigo e o novo precisam coexistir enquanto os deploys avançam. Transações: um pedido e sua reserva de estoque antes comitavam juntos. Agora vivem em dois bancos e você precisa de sagas, compensação e tabelas de outbox para recuperar algo mais fraco do que tinha. Observabilidade: um stack trace vira um trace distribuído, que você precisa construir e manter.
Nenhum desses problemas é exótico. Todos têm solução. Mas cada um é um imposto permanente, pago em cada feature, para sempre.
O monólito modular
A alternativa não é uma bola de lama gigante. É um único deployável com fronteiras internas rígidas: um módulo por domínio, cada um com a própria interface pública, as próprias tabelas que nenhum outro módulo lê diretamente, e uma regra de import imposta por ferramenta para que a fronteira não possa ser cruzada por descuido.
Você ganha o que os microsserviços prometiam, dono claro e evolução independente, mantendo um deploy, uma transação de banco, um trace, um stack trace. Em TypeScript isso é pastas mais regras de lint e um index no nível do pacote que é o único import permitido. Simples, e se sustenta se o time for disciplinado.
Quando um serviço merece a fronteira
Um serviço só deveria existir quando a fronteira se paga, e existem quatro razões honestas.
- Escala independente: uma parte do sistema precisa de 50 instâncias e o resto precisa de 2, e elas disputam os mesmos recursos.
- Runtime diferente: o transcodificador de vídeo precisa de GPU e Python; a API é Node.
- Dono por time: dois times entregam em ritmos diferentes e ficam bloqueando o deploy um do outro.
- Isolamento de segurança ou compliance: a parte que toca dados de cartão ou prontuários precisa viver em um perímetro menor e auditado separadamente.
Se você não consegue nomear uma dessas, está pagando o imposto por causa de um diagrama.
Sinais de que um serviço deveria ter sido módulo
Alguns padrões aparecem sempre. Dois serviços que são sempre deployados juntos, em ordem fixa, porque não funcionam com versões descasadas. Um serviço cujo único cliente é outro serviço. Um banco "compartilhado" em que três serviços escrevem. Endpoints que existem só para o serviço A ler as tabelas do serviço B. Uma feature que precisa de pull request em quatro repositórios. Qualquer um desses significa que a fronteira está no lugar errado, ou não deveria existir.
Extrair é mais barato que juntar
A boa notícia é que o erro é assimétrico. Extrair um módulo bem delimitado para um serviço depois é trabalho mecânico: a interface já existe, os dados já estão separados, você troca uma chamada de função por um client. Juntar dois serviços que cresceram separados, com modelos duplicados, validações divergentes e dois conjuntos de migrations, são meses de trabalho cuidadoso que ninguém quer financiar.
Então o padrão deveria ser módulo. Projete como se pudesse virar serviço, mantenha a fronteira limpa, e deixe no monólito até uma das quatro razões aparecer. A maioria nunca vai aparecer, e é exatamente esse o ponto.