O que é um monólito modular
Um monólito modular é uma única aplicação implantável com fronteiras internas de módulos bem definidas. Externamente, ele parece exatamente com um monólito: um processo, um build, um banco de dados, um deploy. Internamente, ele é organizado em módulos que detêm seus próprios dados e expõem uma interface pública restrita, e essas fronteiras são impostas, e não apenas sugeridas.
Isso não é um compromisso ou um degrau intermediário com o qual você se contenta. É uma arquitetura legítima que muitos sistemas nunca deveriam abandonar. Ela mantém as propriedades que tornam o software fácil de construir — chamadas in-process, uma única transação, um único pipeline, refatorações baratas — e adiciona a disciplina que impede que uma base de código em crescimento se torne um emaranhado.
A distinção que importa é entre um implantável e um bloco desestruturado. Um monólito tradicional em camadas não possui noção de propriedade: qualquer controller pode acessar qualquer tabela, e uma alteração gera impactos em toda a base de código. Um monólito modular estabelece que o módulo de faturamento é a única entidade que entende de faturas, e todos os outros se comunicam com ele através de uma porta que ele mesmo controla.
Por que esta é a escolha padrão sensata
A maioria das equipes recorre a microserviços para resolver problemas que não possuem. Um monólito modular resolve os problemas que elas realmente têm — uma base de código difícil de alterar, propriedade incerta e entrega lenta — sem adicionar a complexidade de rede entre as partes.
As vantagens práticas são substanciais:
- Chamadas em processo são gratuitas. Um módulo chamando outro módulo é apenas uma chamada de função, não uma requisição com timeout, política de retry e modo de falha.
- Transações são reais. Um caso de uso que toca em um módulo é commitado atomicamente. Não há saga, não há compensação e não há janela de inconsistência.
- Refatoração é apenas um commit. Mover um limite, renomear um conceito ou mesclar dois módulos é um trabalho comum, não uma migração com dual writes e cutover.
- Operações permanecem simples. Um build, um deploy, um conjunto de logs, um on-call. Isso não é um detalhe irrelevante para uma equipe de cinco pessoas.
- O domínio tem tempo para se estabilizar. Você pode adiar decisões de limites até entender o negócio, em vez de congelar suposições na infraestrutura.
O custo honesto é que os módulos não falham nem escalam de forma independente, e o bug de um único módulo pode derrubar a aplicação inteira. Para a maioria das equipes e para a maioria dos estágios de um produto, esse é um trade-off que vale a pena.
Módulos seguem capacidades de negócio
Um módulo deve mapear para uma capacidade de negócio, e não para uma camada técnica. Faturamento, catálogo, envio, identidade e notificações são capacidades. controllers, services, repositories e utils são camadas, e agrupar por elas produz o clássico monólito em camadas, onde cada mudança de funcionalidade exige alterações em quatro diretórios diferentes.
Uma boa fronteira de módulo possui as mesmas propriedades que uma boa fronteira de serviço:
- Contém um conjunto coerente de regras que mudam juntas.
- Esconde muito mais do que expõe.
- Pode ser compreendido por uma única equipe sem a necessidade de ler o restante do sistema.
Concretamente, cada módulo é uma pasta com sua própria estrutura interna:
src/modules/billing/
domain/ # entities, value objects, invariants
invoice.ts
money.ts
application/ # use cases and orchestration
billing-service.ts
order-handlers.ts
infra/ # persistence and external clients
invoice-repository.ts
stripe-client.ts
index.ts # the only public entry point
As pastas domain e infra são privadas. O arquivo index.ts re-exporta o pequeno conjunto de tipos e serviços que o restante da aplicação tem permissão para usar. Esse arquivo único é o contrato do módulo e deve ser pequeno o suficiente para ser lido em um minuto.
Se você já viu a regra de dependência da Clean Architecture, esta é a mesma ideia aplicada ao nível de módulo: o domínio no centro não depende de nada externo, e a infraestrutura depende do domínio, e não o contrário.
O shared kernel
Alguns conceitos genuinamente não pertencem a nenhum módulo específico. Dinheiro, um CustomerId, um Clock ou um tipo Result são usados em todos os lugares e não pertencem a ninguém. Coloque-os em um pacote shared pequeno e explícito, e mantenha-o deliberadamente minúsculo.
src/shared/
money.ts # value object, no dependencies
ids.ts # branded id types
clock.ts # a testable time source
Um shared kernel é um ponto de acoplamento, portanto, trate o seu crescimento como um sinal de alerta. No momento em que shared contiver um serviço, um repositório ou qualquer coisa que conheça uma regra de negócio, ele terá se tornado um módulo sem dono, e todos os outros módulos passarão a depender dele. A regra geral é que o shared contenha tipos e funções puras, nunca orquestração ou estado.
Cada módulo é dono de seus dados
A regra que dá poder aos monólitos modulares é a propriedade dos dados (data ownership). Cada módulo é o único escritor de suas tabelas ou de seu schema, e nenhum outro módulo lê essas tabelas diretamente. O acesso entre módulos ocorre através da interface do módulo proprietário ou por meio de um evento.
Em um único banco de dados, você tem três formas práticas de expressar essa propriedade:
- Schemas separados.
billing.invoices,catalog.products,shipping.shipments. O sinal mais claro, que se mapeia perfeitamente para uma futura divisão de banco de dados. - Prefixos de tabela.
billing_invoices,catalog_products. Mais simples, com a mesma intenção. - Bancos de dados separados desde o início. A fronteira mais forte, mas você perde o “superpoder” de transações únicas entre módulos e adiciona trabalho operacional.
A maioria dos monólitos modulares deve começar com um único banco de dados e schemas separados. A chave é a regra de propriedade, não a separação física: apenas o repositório do módulo proprietário toca em suas tabelas. Se o módulo de envios precisa do endereço de um cliente, ele solicita ao módulo de clientes; ele não faz um SELECT de customers.
É isso que torna a extração possível posteriormente. Quando um módulo já é dono de seus dados e se comunica através de uma interface, movê-lo para seu próprio serviço torna-se uma mudança de deploy, e não um redesenho do sistema.
Forçando limites com ferramentas
Limites que são apenas convenções tendem a se degradar. Sob a pressão de prazos, importar o repositório de outro módulo é sempre mais rápido do que adicionar um método à sua interface, e um atalho rapidamente se torna vinte. Torne o limite mecânico.
Uma regra de dependência é fácil de definir e fácil de verificar: um módulo pode importar seus próprios arquivos e a interface pública de outros módulos, e nada mais. Ferramentas como eslint-plugin-boundaries e dependency-cruiser podem expressar isso e interromper o build.
{
"forbidden": [
{
"name": "no-cross-module-internals",
"from": { "path": "^src/modules/([^/]+)/" },
"to": {
"path": "^src/modules/(?!$1)([^/]+)/(?!index\\.ts).+"
}
},
{
"name": "no-cycles",
"from": {},
"to": { "circular": true }
}
]
}
Duas regras fazem a maior parte do trabalho: não importar internos de outro módulo e não permitir dependências circulares. Adicione uma entrada de CODEOWNERS por módulo para que as revisões cheguem às pessoas que são donas do código, e trate a alteração de uma interface como uma pequena mudança de API — ela merece uma segunda análise.
Comunicação in-process sem emaranhados
O modo de falha de um monólito é um grafo de dependências onde tudo aponta para tudo. Um monólito modular mantém esse grafo acíclico e raso. Existem duas formas de um módulo utilizar outro.
Chamar a interface pública. Quando o chamador precisa de uma resposta imediata, injete o serviço do outro módulo e chame-o. O módulo de pedidos chama catalog.getProduct(sku) para precificar um item. A chamada é síncrona e in-process, portanto é rápida e falha como uma exceção, não como um timeout.
export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly catalog: CatalogService,
) {}
async execute(input: PlaceOrderInput): Promise<OrderId> {
const order = Order.create(input.customerId, input.lines);
for (const line of order.lines) {
const product = await this.catalog.getProduct(line.sku);
if (!product.isAvailable()) throw new OutOfStock(line.sku);
order.priceLine(line, product.priceCents);
}
await this.orders.save(order);
return order.id;
}
}
Publicar um evento. Quando o chamador não precisa de uma resposta, ele anuncia um fato e outros módulos reagem. O módulo de pedidos publica order.placed; faturamento, analytics e notificações assinam esse evento. O módulo de pedidos não sabe que eles existem, e é isso que remove o acoplamento.
Eventos também quebram ciclos. Se o módulo de pedidos precisa de um efeito colateral do módulo de envios, e o de envios já depende do de pedidos, uma chamada direta criaria um loop. Um evento permite que o módulo de envios reaja sem que o de pedidos dependa dele. Mantenha o número de chamadas diretas entre módulos reduzido; se dois módulos se chamam constantemente, eles provavelmente são um único módulo ou a fronteira entre eles está no lugar errado.
Um banco de dados, schemas separados
Um único banco de dados é uma funcionalidade, não um compromisso. Ele oferece transações, foreign keys, um único connection pool e um histórico de migrações unificado. O que você perde é o isolamento físico, e você substitui isso pela regra de ownership.
Dentro de um único banco de dados, prefira um schema por módulo. Isso mantém os nomes das tabelas limpos, torna o ownership visível em cada query e fornece a cada módulo um namespace para migrações. Quando um módulo for extraído posteriormente, seu schema irá junto com ele.
Evite foreign keys entre módulos. Uma foreign key de billing.invoices para catalog.products acopla fortemente os dois módulos no nível do banco de dados: o catálogo não pode deletar ou reestruturar sem considerar o faturamento, e a extração exige a remoção da constraint. Em vez disso, armazene o id e valide-o através da interface. O guia do PostgreSQL aborda schemas e constraints em detalhes.
As migrações merecem a mesma disciplina. Cada módulo é dono de seus arquivos de migração, e a inicialização da aplicação ou uma etapa de migração os aplica em ordem. Como há apenas um deploy, você pode migrar e liberar a versão juntos, o que é um luxo que serviços independentes não possuem.
Transações e consistência dentro de um módulo
O “superpoder” da transação única só funciona quando um caso de uso permanece dentro de um único módulo. Faça com que esse seja o cenário comum. Um caso de uso que carrega um agregado, valida um invariante e o grava de volta deve ser uma única transação que ou seja commitada totalmente ou sofra rollback de forma limpa.
await db.transaction(async (tx) => {
const order = await orders.getForUpdate(orderId, tx);
order.confirm();
await orders.save(order, tx);
await outbox.add(
{ type: "order.confirmed", orderId: order.id },
tx,
);
});
Dois padrões mantêm a consistência entre módulos íntegra.
The outbox. Grave o evento de domínio em uma tabela outbox na mesma transação da alteração de estado; em seguida, um dispatcher o publica. Isso garante que o evento não seja perdido caso o processo trave entre o commit e a publicação, sendo o mesmo padrão que você utilizaria após a extração.
A process manager. Quando um fluxo abrange vários módulos, um pequeno coordenador pode ouvir eventos e emitir o próximo comando, lidando explicitamente com retentativas e timeouts. Este é o equivalente in-process de uma saga, e é muito mais simples do que uma distribuída porque o estado de coordenação reside em uma tabela normal.
Não tente implementar uma transação distribuída. Se um caso de uso genuinamente abrange vários módulos e precisa ser atômico, isso geralmente é um sinal de que esses módulos, na verdade, são um único módulo.
O caminho para a extração
O motivo para investir em boundaries é a opcionalidade. Um monólito modular cujos módulos se comunicam através de interfaces e eventos pode ser desmembrado posteriormente, um módulo por vez, utilizando a abordagem strangler fig.
- Escolha o módulo com a boundary mais clara e a maior pressão. Escalonamento independente, a cadência de uma equipe separada ou requisitos de compliance são bons motivos.
- Confirme se ele é dono de seus próprios dados. Se outros módulos ainda leem suas tabelas, corrija isso primeiro, roteando-os através da interface.
- Dê a ele seu próprio banco de dados e pipeline. Mova seu schema, aponte seu repository para o novo store e mantenha a interface estável.
- Substitua chamadas in-process por chamadas de rede ou eventos. Quem chama já depende de uma interface, portanto, isso é uma mudança de adapter, não uma reescrita.
- Troque o event dispatcher por um broker. O outbox pattern garante que o fluxo de eventos mal mude quando o transporte se tornar Kafka ou RabbitMQ.
Como a costura (seam) já existe, cada etapa é delimitada. Este é o argumento mais forte a favor do monólito modular: ele não é um destino diferente dos microservices, mas sim a opção de chegar lá deliberadamente, apenas para as partes que justificarem isso.
Testando módulos em isolamento
As fronteiras de módulos trazem benefícios reais nos testes. Como um módulo expõe uma interface pública e é dono de seus próprios dados, você pode testá-lo individualmente sem precisar iniciar a aplicação inteira.
- Testes de domínio são puros e rápidos. Instancie o agregado, execute as regras e valide o resultado. Sem banco de dados, sem HTTP.
- Testes de interface de módulo acionam o serviço público do módulo contra um banco de dados de teste limitado ao seu schema. Eles verificam o contrato do qual outros módulos dependem.
- Testes de contrato de eventos validam se o módulo publica os eventos que os consumidores esperam, com os campos necessários.
- Testes end-to-end exercitam a camada HTTP para um pequeno número de fluxos críticos. Mantenha-os em pouca quantidade, pois são lentos.
A alternativa em camadas força cada teste relevante a passar por todas as camadas, e é por isso que essas suítes se tornam lentas e instáveis. Testar na fronteira do módulo mantém a maioria dos testes rápidos, enquanto ainda protege as interfaces que realmente importam.
Monólito modular versus monólito em camadas
Vale a pena ser preciso, pois ambos são “um monólito”, mas apenas um é modular.
| Monólito em camadas | Monólito modular | |
|---|---|---|
| Agrupamento | Por camada técnica | Por capacidade de negócio |
| Impacto de mudanças | Abrange todas as camadas | Permanece em um único módulo |
| Acesso a dados | Qualquer camada acessa qualquer tabela | O módulo é dono de suas tabelas |
| Propriedade (Ownership) | Pouco clara | Uma equipe por módulo |
| Testabilidade | Testes end-to-end dominam | Testes com escopo de módulo |
| Extração | Uma reescrita completa | Uma mudança delimitada |
O monólito em camadas não está errado para uma aplicação pequena; ele é simples e familiar. Ele se torna um problema quando muitas pessoas trabalham nele, pois não há uma “costura” pela qual dividir o trabalho e nenhuma maneira de raciocinar sobre uma mudança localmente.
O modo de falha: uma grande bola de lama
O monólito modular falha de uma maneira específica: as fronteiras se dissolvem. Tudo começa com um atalho razoável que acaba se tornando a norma.
Sinais de alerta:
- Um módulo importa a pasta
infrade outro módulo. - Dois módulos escrevem na mesma tabela.
- Um pacote
utilsousharedcresce até se tornar uma segunda aplicação. - Alterar o schema de um módulo quebra o build de outro módulo.
- O grafo de dependências possui um ciclo, geralmente introduzido por um único import de “só desta vez”.
A prevenção exige a mesma disciplina da qual o restante da arquitetura depende: regras de lint que falham o build, code owners por módulo, uma interface pública pequena e uma cultura de review que trata um import interno como um bug. Fronteiras são baratas de manter e caras de restaurar, portanto, imponha-as desde o primeiro módulo.
Eventos dentro de um único processo
Eventos in-process desacoplam módulos sem a necessidade de um broker. Um pequeno dispatcher recebe um fato publicado e chama os handlers inscritos, tudo dentro do mesmo processo e, se você desejar, na mesma transação.
type DomainEvent = { type: string; occurredAt: string };
type Handler = (event: DomainEvent, tx?: Transaction) => Promise<void>;
class EventBus {
private handlers = new Map<string, Handler[]>();
on(type: string, handler: Handler) {
this.handlers.set(type, [...(this.handlers.get(type) ?? []), handler]);
}
async publish(event: DomainEvent, tx?: Transaction) {
for (const handler of this.handlers.get(event.type) ?? []) {
await handler(event, tx);
}
}
}
Dois alertas. Se os handlers forem executados dentro da transação do chamador, um handler lento prolonga a transação e qualquer falha causa o rollback de todo o caso de uso. Se eles forem executados após o commit, uma queda entre os dois pode resultar na perda do evento. O padrão outbox resolve isso gravando o evento em uma tabela outbox na mesma transação e, em seguida, realizando o dispatch a partir dali.
await db.transaction(async (tx) => {
await orders.save(order, tx);
await tx.insert(outbox).values({
type: "order.placed",
payload: order.toEvent(),
});
});
Como o evento é commitado junto com o estado, ele não pode ser perdido, e um relay pode tentar o dispatch novamente até que todos os inscritos o tenham processado. Quando um módulo é extraído posteriormente, o relay é a única coisa que muda.
Um gerenciador de processos para fluxos entre módulos
Quando um caso de uso abrange múltiplos módulos e precisa reagir a falhas, um process manager coordena isso explicitamente, em vez de esconder o fluxo em uma cadeia de manipuladores de eventos. Ele escuta eventos, mantém seu próprio estado e emite comandos.
class PlaceOrderProcess {
async onOrderPlaced(event: OrderPlaced) {
await this.catalog.reserve(event.orderId, event.lines);
}
async onReservationFailed(event: ReservationFailed) {
await this.orders.cancel(event.orderId, "out_of_stock");
await this.notifications.send(event.customerId, "order_cancelled");
}
async onReservationConfirmed(event: ReservationConfirmed) {
await this.payments.charge(event.orderId, event.totalCents);
}
}
O process manager é o equivalente in-process de uma saga. Ele torna o caminho feliz (happy path) e o caminho de compensação visíveis em um único lugar, que é exatamente o que a coreografia esconde. Mantenha o estado do processo em uma tabela comum para que, em caso de reinicialização, ele retome de onde parou.
Versionando a interface de um módulo
A index.ts de um módulo é uma API interna e merece o mesmo cuidado que uma API pública. Outros módulos são compilados com base nela, portanto, uma alteração descuidada pode quebrar o build deles.
- Adicione, não quebre. Adicione parâmetros opcionais e novos métodos; evite alterar uma assinatura existente.
- Mantenha a superfície reduzida. Cada exportação é uma promessa. Se um tipo não precisa sair do módulo, não o exporte.
- Deprecie em etapas. Marque o método antigo, migre quem o utiliza em commits separados e, então, remova-o. Como se trata de um único codebase, você consegue pesquisar por todos os chamadores.
- Teste o contrato. Testes de interface de módulo protegem a promessa que você fez a outros módulos.
Isso é mais barato do que versionar uma API de rede porque não há ordem de deploy a respeitar, mas a disciplina é a mesma, e é isso que impede que uma futura extração se torne uma reescrita completa.
Migrations por módulo
Como um monólito modular possui apenas um banco de dados, é tentador manter um único conjunto gigante de arquivos de migration. Isso recria um acoplamento que a arquitetura está tentando remover. Em vez disso, deixe que cada módulo seja dono de suas próprias migrations, limitadas ao seu schema ou prefixo de tabela.
migrations/
catalog/ 20260901_add_product_status.sql
orders/ 20260903_add_order_confirmed_at.sql
billing/ 20260905_add_invoice_paid_at.sql
Uma migration que altera tabelas de outro módulo é, na verdade, uma violação de fronteira e deve ser reprovada no code review. Manter as migrations modulares significa que a regra de ownership se aplica inclusive ao schema, e torna a migração das tabelas de um módulo para seu próprio banco de dados apenas uma questão de reexecutar a pasta correspondente.
Quando mesclar módulos novamente
Nem toda fronteira é definida corretamente na primeira vez. Se dois módulos sempre mudam juntos, compartilham a mesma transação e fazem chamadas mútuas em ambas as direções, eles são, na verdade, um único módulo com uma divisão artificial. Mesclá-los novamente é uma decisão legítima e saudável.
Os sinais são concretos: um pull request rotineiramente altera ambos os módulos, a interface entre eles muda a cada nova feature e o grafo de dependências possui um ciclo que você precisa contornar constantemente. Mesclar é barato em um monólito — basta mover o código, colapsar a interface e atualizar os imports — e isso remove um custo de coordenação que só tenderia a piorar. O objetivo são fronteiras claras, e não um número específico delas.
Mantendo as junções saudáveis
As fronteiras se degradam silenciosamente, portanto, verifique-as periodicamente em vez de esperar por uma reescrita. Algumas fitness functions automatizadas detectam esse desvio enquanto ainda é barato corrigi-lo.
- Falhe o CI quando um módulo importar as internals de outro módulo.
- Falhe o CI quando o grafo de dependências contiver um ciclo.
- Falhe o CI quando uma migration referenciar uma tabela pertencente a outro módulo.
- Reporte o número de arquivos e exportações públicas por módulo como uma tendência; um módulo que não para de crescer é uma fronteira que pode estar no lugar errado.
- Exija uma revisão de
CODEOWNERSpara alterações na interface pública de um módulo.
Nenhuma dessas medidas exige nova infraestrutura. São testes e regras de lint comuns e, juntos, transformam o “combinamos de manter as fronteiras” em algo que o build impõe.
Melhores práticas
- Defina módulos por capacidade de negócio, nunca por camada técnica.
- Atribua a cada módulo um único arquivo público
indexe mantenha-o pequeno. - Faça com que cada módulo seja o único escritor de suas próprias tabelas ou schema.
- Proíba importações de internos entre módulos e ciclos através de regras de lint no CI.
- Prefira uma chamada de interface direta quando precisar de uma resposta, e um evento quando não precisar.
- Mantenha o grafo de dependências acíclico e raso; se dois módulos chamarem um ao outro, funda-os ou redefina a divisão.
- Use um banco de dados com um schema por módulo e evite chaves estrangeiras entre módulos.
- Mantenha um caso de uso dentro de um único módulo para que ele caiba em uma única transação.
- Use um outbox para consistência entre módulos em vez de transações distribuídas.
- Teste os módulos através de sua interface pública, com alguns testes end-to-end na borda.
- Mantenha as interfaces preparadas para eventos para que um módulo possa ser extraído sem a necessidade de reescrita.
Erros comuns
- Chamar de monolito modular, mas compartilhar tabelas e importar internals.
- Organizar pastas por camada e acreditar que os módulos são reais.
- Ignorar as regras de lint porque “todo mundo conhece a convenção”.
- Deixar que um pacote de utilities compartilhadas se torne um ponto de acoplamento oculto.
- Executar um fluxo entre módulos como uma transação distribuída quando os módulos deveriam ser um só.
- Adicionar dependências circulares e tentar mascará-las com dynamic imports.
- Extrair um serviço antes que o módulo tenha uma interface limpa ou seja dono de seus próprios dados.
- Colocar regras de negócio em controllers, impedindo que os módulos sejam testados isoladamente.
- Tratar o estilo como algo temporário e nunca aplicar as boundaries.
- Assumir que um único deploy significa um único domínio de falha e pular o trabalho básico de resiliência.
Próximos passos
Se, futuramente, uma pressão concreta justificar a divisão, o guia de Microservices explica o que você ganha, qual é o custo e como extrair um módulo por vez. Para organizar o código dentro de cada módulo, leia sobre Clean Architecture e, para desacoplar módulos através de fatos em vez de chamadas, leia sobre Event-Driven Architecture. Quando precisar dos padrões de armazenamento por trás da propriedade de módulos, o guia de PostgreSQL aborda schemas, transações e constraints.