Architecture

Monólito Modular

Um monólito modular é um sistema com implantação única e fronteiras internas rígidas. Você mantém chamadas em processo, uma única transação e um único pipeline, enquanto cada módulo de negócio detém seus próprios dados e expõe uma interface pública reduzida — permitindo que ele se torne um serviço futuramente, se necessário.

intermediate14 min readUpdated 16 de set. de 2026
src/modules/billing/index.ts
ts
// src/modules/billing/index.ts
export type { Invoice, InvoiceId } from "./domain/invoice.js";
export { BillingService } from "./application/billing-service.js";
export { onOrderPlaced } from "./application/order-handlers.js";

// Everything under ./domain and ./infra stays private.
// Other modules import from this file and nothing deeper.
Implantação
Uma única unidade de deploy
Fronteiras
Módulos por capacidade
Dados
Um banco de dados, um dono por tabela
Comunicação
Interfaces em processo e eventos
Superpoder
Transações ACID
Modo de falha
Big ball of mud

Por que importa

Por que o monólito modular é a escolha padrão sensata

Um deploy, um pipeline

Um único build, uma única suíte de testes e um único rollback. Não há orquestração, service discovery ou rede entre os módulos.

Costuras internas fortes

Cada módulo de negócio detém seu domínio e dados, expondo uma interface pública estreita. As fronteiras são impostas por ferramentas, não por boas intenções.

Um caminho para a extração

Como os módulos conversam através de interfaces e eventos, qualquer um deles pode se tornar seu próprio serviço posteriormente sem a necessidade de reescrever os demais.

O panorama completo

As três regras que mantêm um monólito modular

Módulos seguem capacidades, cada um detém seus próprios dados e eles se comunicam apenas através de uma interface pública ou de um evento.

Módulo

Capacidade

Um módulo por capacidade de negócio, contendo seu próprio código de domínio, aplicação e infraestrutura atrás de uma superfície pública.

Propriedade de dados

Isolamento

Um único banco de dados, mas cada módulo escreve apenas em suas próprias tabelas ou schema. Ninguém faz joins através de fronteiras de módulos.

Interface

Contrato

Módulos chamam uma interface publicada ou publicam um evento. Os internos são privados, e é isso que torna a costura real.

HTML5 de uma olhada

Como são boas fronteiras de módulo

Módulos

Pastas como billing, catalog e shipping, cada uma autocontida.

Interface pública

Um único arquivo index é a única coisa que outros módulos podem importar.

Tabelas proprietárias

Um módulo é o único escritor de suas tabelas ou schema.

Eventos de domínio

Um módulo anuncia fatos para que outros possam reagir sem uma chamada direta.

Grafo acíclico

As dependências apontam em uma única direção e nunca fazem loops.

Fronteiras impostas

Regras de lint falham o build quando um módulo tenta acessar o interior de outro.

Fluxo

O fluxo de uma requisição através dos módulos

Tudo acontece em um único processo, então o caso comum é uma única transação e uma chamada de função direta em vez de um salto de rede.

  1. 1

    A camada HTTP mapeia a requisição

    Um controller valida a entrada e a traduz em uma chamada para o módulo proprietário. Ele não detém regras de negócio próprias.

  2. 2

    O módulo proprietário trata o caso de uso

    Seu serviço de aplicação carrega o aggregate, aplica as regras e decide quais mudanças ocorrem. É aqui que o domínio reside.

  3. 3

    O módulo usa seus próprios dados

    Ele lê e escreve através de seu próprio repository, tocando apenas as tabelas que possui. Tabelas de outros módulos não estão envolvidas.

  4. 4

    Ele chama a interface de outro módulo

    Se precisar de um fato ou ação de outra capacidade, ele chama o serviço público daquele módulo ou emite um evento de domínio.

  5. 5

    A transação é commitada

    Quando o trabalho permanece dentro de um módulo, é uma única transação ACID. A consistência entre módulos utiliza eventos e um outbox.

  6. 6

    A resposta retorna

    O controller mapeia o resultado de volta para HTTP. Sem fronteira de serialização, sem timeouts, sem falhas parciais entre módulos.

O guia completo

Monólito Modular: Tudo que voce precisa saber

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 infra de outro módulo.
  • Dois módulos escrevem na mesma tabela.
  • Um pacote utils ou shared cresce 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 CODEOWNERS para 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 index e 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.

Na pratica

Módulo, chamada, fronteira, evento

As quatro peças que tornam um monólito modular em vez de apenas em camadas.

src/modules/catalog/index.ts
// Public surface of the catalog module.
export type { Product, ProductId, Sku } from "./domain/product.js";
export { CatalogService } from "./application/catalog-service.js";
export { onStockChanged } from "./application/stock-handlers.js";

// Private by convention and by lint rule:
// ./domain/**  entities, value objects, invariants
// ./infra/**   repositories, ORM mappings, clients

Interface pública versus acesso interno

Um módulo que importa o repository de outro módulo está acoplado ao seu schema e seus internos. Um módulo que importa a interface pode ser substituído ou extraído.

Preferir
import { CatalogService } from "../../catalog/index.js";

// The order module knows only what catalog promises.
const product = await catalog.getProduct(sku);
Evitar
import { ProductRepository } from "../../catalog/infra/product-repository.js";

// Now orders depends on catalog's tables and ORM mappings.
// A schema change in catalog breaks orders.
const product = await productRepository.findBySku(sku);

Monólito modular versus microserviços para times pequenos

Um time pequeno obtém as mesmas fronteiras internas com um custo operacional muito menor. Extraia um serviço apenas quando surgir um motivo concreto.

Preferir
// One process, one transaction, one deploy.
await this.orders.save(order);
await this.catalog.reserve(order.lines);

// Refactor and rename across modules in one commit.
Evitar
// Three services and a saga for the same use case.
const order = await orders.create(input);
await inventory.reserve(order.id);   // network
await payments.charge(order.id);     // network
// Any of the above can fail after the others succeeded.

Trade-offs

Por que começar com um monólito modular?

Este estilo mantém a maior parte da simplicidade de um monólito, enquanto oferece a opção de distribuir posteriormente. O detalhe é que as fronteiras só existem se você as impuser.

Strengths

  • Transações permanecem simples

    Um caso de uso que toca um único módulo é uma única transação ACID com rollback real. Sem sagas, sem ações compensatórias, sem consistência eventual para explicar.

  • Refatoração é barata

    Renomear uma interface, mover uma classe ou dividir um módulo é um commit normal. Não há contrato versionado ou janela de migração para negociar.

  • Operações permanecem leves

    Um build, um deploy, um dashboard, um rodízio de on-call. Um time pequeno pode operá-lo sem a necessidade de um grupo de plataforma.

  • A costura já está lá

    Como os módulos se comunicam via interfaces e eventos, extrair um posteriormente é uma mudança delimitada em vez de um projeto de arqueologia.

Trade-offs

  • Disciplina é tudo

    Nada no runtime impede que um módulo importe o repository de outro. Sem regras de lint e review, as fronteiras corroem em semanas.

  • Um deploy é um único raio de explosão

    Um memory leak ou um crash em um módulo pode derrubar a aplicação inteira. Módulos não falham independentemente como os serviços fazem.

  • Escalabilidade é tudo ou nada

    Se um módulo precisar de muito mais CPU que os demais, você escala a aplicação inteira até que extraia esse módulo.

  • Memória compartilhada é uma tentação

    Globais em processo e caches compartilhados facilitam o acoplamento invisível entre módulos. Trate estado compartilhado como uma violação de fronteira.

Perguntas frequentes

Perguntas frequentes

Keep learning

Related topics from the roadmap.

$ comecar a aprender

Pronto para aprender Modular Monolith?

Nosso tutorial interativo te guia por Modular Monolith passo a passo — com quizzes e codigo real que voce pode executar no navegador.