NOVO Série Monolito Modular na Prática no ar

Intermediário8 min de leituraSérie: Monolito Modular na Prática

Monorepo, monolito modular e microsserviços: três eixos que todo mundo confunde

Onde o código mora, quantos processos você implanta e quão acoplado é o código são decisões diferentes. Um projeto real com um código, três deployables e fronteiras verificadas no CI.

Publicado em · Read in English

TL;DR

  • Monorepo responde onde o código mora. Monolito vs. microsserviços responde quantas unidades você implanta e se elas são donas do próprio código e dos próprios dados. São eixos independentes.
  • Dá para ter um monolito modular com vários deployables: um código só, um processo para a API, outro para ingestão e outro para jobs. Isso dá escala e isolamento de falha sem distribuir o domínio.
  • Fronteira de módulo que só existe na wiki não existe. Ela precisa quebrar o build, e isso vale ainda mais quando parte do código é escrito por agentes de IA.

Introdução#

Em quase toda conversa sobre arquitetura que eu participo, alguém fala “a gente vai de monorepo” como resposta a “monolito ou microsserviços?”. As duas perguntas parecem a mesma, mas não são. E a confusão custa caro: times que juntam tudo num repo achando que ganharam um monolito modular, ou que espalham dez serviços por dez repos e acabam com um monolito distribuído.

Ao mesmo tempo, o pêndulo voltou. Depois de uma década de “microsserviço de cara”, cada vez mais gente começa com um monolito modular, e as ferramentas de IA tornaram o monorepo mais atraente: um agente com o repositório inteiro no contexto faz uma mudança que atravessa API, worker e contrato num PR só.

Este post separa os eixos e mostra, com um projeto real e executável, como fica um sistema com um código, três processos e fronteiras verificadas no CI.

Pré-requisitos#

  • Familiaridade com a ideia de microsserviços e de deploy em containers
  • Docker com Compose v2, se quiser rodar o exemplo
  • Python 3.12+ e Poetry 2.2+ para rodar o lint de fronteiras localmente

Conceito: três eixos, não um#

Eixo 1 — onde o código mora#

  • Monorepo: todos os apps e bibliotecas num repositório, com um histórico, um CI e mudanças atômicas entre eles.
  • Polyrepo: um repositório por app ou biblioteca, com versões publicadas entre eles.

Isso é decisão de organização e ferramental. O Google roda milhares de serviços independentes num monorepo. Também existe monolito espalhado em vários repos, com o “core” publicado como biblioteca.

Eixo 2 — quantos processos você implanta#

Um artefato ou vários. Atenção: vários deployables não significa vários serviços. Três processos rodando o mesmo código, sobre o mesmo banco, ainda são um sistema só.

Eixo 3 — quão independentes são o código e os dados#

É o eixo que importa e o que menos aparece em discussão. Cada parte é dona do seu modelo e do seu dado, e só conversa com as outras por uma interface explícita? Ou qualquer classe importa qualquer classe e qualquer query faz join em qualquer tabela?

Cruzando os eixos 2 e 3:

Fronteiras fracasFronteiras fortes
Um deployableBig ball of mudMonolito modular
Vários deployablesMonolito distribuído (o pior dos mundos)Microsserviços, ou monolito modular multi-processo

E o eixo 1 é ortogonal a tudo isso: qualquer célula da tabela pode morar num monorepo ou em vários repos.

“Monorepo onde cada app tem seu deploy” é usado?#

Muito, e em duas variantes que vale não confundir:

  1. Microsserviços num monorepo. Cada app tem código e dados próprios. O monorepo só facilita refatorações e contratos compartilhados. Ferramentas como Nx, Turborepo, Bazel ou Gradle multi-projeto constroem e implantam só o que foi afetado pela mudança.
  2. Monolito modular com vários entrypoints. Um código só, empacotado em imagens diferentes, cada uma ligando só os módulos que precisa. Uma mudança num módulo potencialmente gera nova versão de todas as imagens, porque todas carregam o mesmo código.

A segunda variante é a do projeto que vou usar como exemplo.

O que a literatura diz (e o que ela não diz)#

O Martin Fowler, em MonolithFirst, e o Sam Newman, em Monolith to Microservices, defendem que microsserviços não deveriam ser o ponto de partida: as fronteiras desenhadas antes de entender o domínio são caras de mover, e cada regra que cruza contextos vira chamada de rede ou saga. Mas o próprio site do Fowler publicou o contraponto do Stefan Tilkov, Don’t start with a monolith: quando você já conhece bem o domínio, começar separado pode ser mais barato do que desmontar um monolito depois.

Então não é “microsserviço de cara é errado”. É: você só deveria distribuir fronteiras que já provou. O monolito modular é o lugar barato para provar.

Mão na massa: um código, três processos#

O exemplo é o smarthome-hub, um hub de casa inteligente multi-tenant em Python (FastAPI) e React. Ele tem três perfis de execução bem diferentes:

  • API HTTP/WebSocket: sensível a latência, escala com usuários.
  • Ingestão de telemetria: consumidor MQTT de vida longa, escala com o número de dispositivos e não pode parar quando a API está ocupada.
  • Trabalho em background: relay do outbox, automações agendadas, notificações e timeout de comandos.

As regras de negócio por trás deles se sobrepõem: ingerir telemetria atualiza o gêmeo digital do dispositivo (devices), que pode disparar uma automação (automations), que envia comandos (commands). Separar isso em três serviços transformaria uma chamada de função em três chamadas de rede.

A decisão (ADR 0001)#

O repositório registra a escolha num ADR. Das três opções consideradas (processo único, microsserviços, monolito modular com vários entrypoints), a terceira venceu:

Repare no simulator: ele não depende do monolito, só do pacote do protocolo, exatamente como um ESP32 real.

O layout do monorepo#

estrutura
apps/
api/ FastAPI + todos os módulos de domínio (o monolito)
ingestor/ entrypoint de ingestão MQTT sobre os mesmos módulos
worker/ entrypoint de background sobre os mesmos módulos
simulator/ casas e dispositivos simulados (só depende do device-protocol)
web/ React + TypeScript
packages/
device-protocol/ tópicos MQTT e schemas das mensagens
contracts/ OpenAPI + tipos TypeScript gerados
infra/ docker compose e configs dos serviços
docs/adr/ decisões de arquitetura

ingestor e worker são pacotes finos: cada um tem a sua composition root e depende de apps/api como dependência de caminho do Poetry. Ou seja, três imagens, um código de domínio.

Subindo e vendo os três processos#

Terminal window
git clone https://github.com/dev-danilocordeiro/smarthome-hub
cd smarthome-hub
make up # sobe tudo e espera os healthchecks
make ps # api, ingestor e worker aparecem como containers separados

O compose fica em infra/ e lê o .env da raiz, por isso o Makefile é o caminho mais curto. Para falar com o compose direto, passe os dois: docker compose --env-file .env -f infra/docker-compose.yml.

Com o simulador rodando, o ingestor absorve a telemetria de 60 dispositivos sem tocar na latência da API, e você pode reiniciá-lo sem derrubar a API:

Terminal window
make simulate # 3 casas x 20 dispositivos
docker compose --env-file .env -f infra/docker-compose.yml restart ingestor # a API continua respondendo
curl localhost:8000/health/ready

As regras que tornam isso um monolito modular#

O ADR define as regras, e elas são poucas:

  • Cada módulo tem as camadas api → infrastructure → application → domain, e uma camada só importa as de baixo.
  • domain não importa framework nenhum (fastapi, sqlalchemy, pydantic, redis…).
  • Um módulo só alcança outro pelo modules/<x>/public.py, ou de forma assíncrona por eventos que passam pelo outbox.
  • Um schema Postgres por módulo, sem foreign key entre schemas. Referência entre módulos é só id.

A última regra é a que mais gente pula, e é a que mais encarece uma extração futura.

Fronteira que quebra o build#

As regras viram contratos do import-linter, gerados por script para não ficarem desatualizados. Um trecho:

apps/api/.importlinter (trecho)
[importlinter]
root_package = smarthome
[importlinter:contract:module-layers]
name = Each module is layered api -> infrastructure -> application -> domain
type = layers
layers =
api
infrastructure
application
domain
containers =
smarthome.modules.automations
smarthome.modules.commands
smarthome.modules.devices
[importlinter:contract:boundary-automations]
name = automations reaches other modules only through their public.py
type = forbidden
source_modules =
smarthome.modules.automations
forbidden_modules =
smarthome.modules.devices.domain
smarthome.modules.devices.application
smarthome.modules.devices.infrastructure
allow_indirect_imports = True

Para ver o contrato funcionando, quebre uma fronteira de propósito. Num arquivo qualquer de automations, importe o domínio de devices direto:

from smarthome.modules.devices.domain import twin # atalho proibido

E rode o lint:

Terminal window
cd apps/api
poetry run lint-imports

A saída aponta o contrato quebrado e a cadeia de imports que causou a violação. No CI, isso reprova o PR.

Testando: o que esse desenho compra#

Siga um comando do clique até o dispositivo. Com o simulador rodando, cada comando tem um trace_id, e no Tempo aparece um único trace atravessando quatro processos: a requisição na API, o publish do worker, o dispositivo tratando o comando e o ingestor recebendo o ack.

Terminal window
make simulate
# na UI (make web-dev) ligue uma lâmpada e copie o trace_id da resposta
# Grafana → Explore → Tempo → cole o trace_id

Isso mostra o ponto central: os processos são separados onde o perfil de execução difere, e o código é um só onde as regras se sobrepõem.

Armadilhas e trade-offs#

Resumo#

TermoResponde aExemplo
MonorepoOnde o código moraGoogle, smarthome-hub
Monolito modularCódigo e dados com fronteiras fortes, um sistemasmarthome-hub
Vários entrypointsProcessos separados sobre o mesmo códigoapi, ingestor, worker
MicrosserviçosUnidades donas de código e dados, implantadas de forma independenteum repo (ou pasta) e um banco por serviço
Monolito distribuídoVários deploys, fronteiras fracasserviços que só sobem juntos

Conclusão#

“Monorepo” é uma escolha de ferramental. “Monolito modular” é uma escolha de acoplamento. “Vários deployables” é uma escolha operacional. Tratar as três como uma só leva a arquiteturas que têm o custo de uma e o benefício de nenhuma.

Mas tudo isso pressupõe que você sabe onde traçar as fronteiras dos módulos. No próximo post da série, vou para a parte mais difícil: separar domínios com DDD estratégico, usando Evans, Vernon e Kononov, num domínio mais rico que uma casa inteligente.

Código no GitHub#

O código deste artigo está no GitHubdev-danilocordeiro/smarthome-hub

Referências

  1. MonolithFirst — Martin Fowler martinfowler.com
  2. Don't start with a monolith — Stefan Tilkov martinfowler.com
  3. Monolith to Microservices — Sam Newman samnewman.io
  4. Why Google Stores Billions of Lines of Code in a Single Repository cacm.acm.org
  5. import-linter import-linter.readthedocs.io

Encontrou um erro? Me avise por e-mail