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

Avançado8 min de leituraSérie: Monolito Modular na Prática

Comunicação entre módulos: chamadas, eventos in-process, outbox e um schema por módulo

Fronteiras garantidas resolvem metade do problema. A outra metade é como os módulos conversam sem se acoplar: quando chamar, quando publicar evento e como não perder evento no caminho.

Publicado em · Read in English

TL;DR

  • Chamada síncrona pela fachada quando você precisa da resposta agora; evento quando o outro módulo só precisa saber que algo aconteceu.
  • Evento in-process sem persistência se perde no primeiro crash. O registro de publicações do Spring Modulith e o transactional outbox fazem “gravado” e “publicado” virarem o mesmo fato.
  • Um schema por módulo, sem foreign key entre schemas. Cada módulo guarda a própria cópia do que precisa. É o preço da autonomia e o que deixa uma extração futura barata.

Introdução#

Até aqui na série separei os eixos (monorepo, monolito, microsserviços), tracei fronteiras com DDD estratégico e fiz o CI garantir essas fronteiras. Falta o que acontece entre elas.

Num monolito modular, é tentador achar que comunicação é trivial: é tudo o mesmo processo, então é só chamar o método. Mas cada chamada direta é uma dependência, e cada dependência é algo que você vai ter que desfazer se um dia extrair o módulo. Por outro lado, transformar tudo em evento cria um sistema que ninguém consegue depurar.

Este post mostra os dois estilos, quando usar cada um e como não perder eventos, com Spring Modulith em Java e com o outbox do smarthome-hub em Python.

Pré-requisitos#

  • Posts anteriores da série, principalmente o de fronteiras no CI
  • Noção de transação em banco relacional
  • JDK 21+, Docker (para o PostgreSQL) e, opcionalmente, o smarthome-hub rodando

Conceito#

Chamar ou publicar?#

PerguntaChamada síncronaEvento
Quem chama precisa da resposta para continuar?SimNão
A operação pode falhar e a falha deve voltar a quem chamou?SimNão
Quantos módulos se interessam?UmVários, e pode crescer
Quem depende de quem?Chamador depende do chamadoOuvinte depende do publicador
ConsistênciaImediata (mesma transação, se quiser)Eventual

Um exemplo na plataforma escolar: ao matricular, o módulo de matrícula precisa saber se a turma tem vaga. Isso é chamada: sem a resposta, não dá para continuar. Depois de confirmar, o pedagógico, a comunicação e as mensalidades precisam saber que a matrícula aconteceu. Isso é evento: matrícula não deveria conhecer nenhum deles.

Repare na direção das setas no grafo de dependências: matricula → turmas por chamada, e pedagogico → matricula e mensalidades → matricula pelo contrato do evento. A matrícula não conhece os ouvintes.

O problema do evento perdido#

Evento in-process parece simples:

  1. Salva a matrícula.
  2. Publica MatriculaConfirmada.
  3. Os ouvintes reagem.

Agora pense no que pode dar errado. Se o processo cai entre o commit e a entrega, o evento se perde. Se o ouvinte roda dentro da transação e falha, ele derruba a matrícula, que não tinha nada a ver com isso. Se roda depois do commit e falha, ninguém fica sabendo.

A solução é a mesma de sempre em sistemas distribuídos, e ela vale dentro de um processo também: gravar a intenção de publicar na mesma transação do dado, e entregar depois, com retry.

Dados: um schema por módulo#

Se o módulo de mensalidades faz JOIN na tabela de matrícula, a fronteira de código não serve para nada: o acoplamento só mudou de lugar. A regra é:

  • Cada módulo é dono de um schema e só ele escreve e lê ali.
  • Sem foreign key entre schemas. Referência a outro módulo é só um id.
  • Se precisa de dado de outro módulo, ou pede pela fachada, ou mantém uma cópia própria alimentada por eventos.

O ganho é que extrair um módulo vira mover um schema. O custo é que integridade referencial entre módulos passa a ser responsabilidade sua.

Mão na massa#

Java: eventos com Spring Modulith#

Terminal window
git clone https://github.com/dev-danilocordeiro/monolito-modular
cd monolito-modular/java
git checkout passo-0 # o estado deste post; o main já está no fim do post seguinte
docker compose up -d postgres
build.gradle.kts
dependencies {
implementation(platform("org.springframework.modulith:spring-modulith-bom:2.1.1"))
implementation("org.springframework.modulith:spring-modulith-starter-core")
implementation("org.springframework.modulith:spring-modulith-starter-jdbc") // registro de publicações
testImplementation("org.springframework.modulith:spring-modulith-starter-test")
}

O projeto usa Spring Boot 4.1.1 com Spring Modulith 2.1.1; o build.gradle.kts completo, com JDBC, PostgreSQL e Flyway, está no repositório.

A chamada síncrona vai pela fachada pública do módulo turmas. A publicação usa o ApplicationEventPublisher comum do Spring, dentro da transação:

escola/matricula/MatriculaService.java
package escola.matricula;
import escola.matricula.internal.MatriculaRepository;
import escola.turmas.TurmasService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class MatriculaService {
private final TurmasService turmas;
private final MatriculaRepository matriculas;
private final ApplicationEventPublisher eventos;
MatriculaService(TurmasService turmas, MatriculaRepository matriculas, ApplicationEventPublisher eventos) {
this.turmas = turmas;
this.matriculas = matriculas;
this.eventos = eventos;
}
@Transactional
public Matricula matricular(NovaMatricula pedido) {
turmas.reservarVaga(pedido.turmaId()); // chamada: precisa da resposta
var matricula = matriculas.save(Matricula.confirmada(pedido));
eventos.publishEvent(new MatriculaConfirmada( // evento: ninguém espera
matricula.estudanteId(), pedido.turmaId(), pedido.anoLetivo(), pedido.nomeSocial()));
return matricula;
}
}
escola/matricula/MatriculaConfirmada.java
package escola.matricula;
import java.util.UUID;
public record MatriculaConfirmada(UUID estudanteId, UUID turmaId, int anoLetivo, String nomeSocial) {}

E o ouvinte, em outro módulo:

escola/pedagogico/AoConfirmarMatricula.java
package escola.pedagogico;
import escola.matricula.MatriculaConfirmada;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class AoConfirmarMatricula {
private final Aprendizes aprendizes;
AoConfirmarMatricula(Aprendizes aprendizes) {
this.aprendizes = aprendizes;
}
@ApplicationModuleListener
void on(MatriculaConfirmada evento) {
aprendizes.criarSeNaoExistir(evento.estudanteId(), evento.nomeSocial());
}
}

O @ApplicationModuleListener junta três coisas: roda depois do commit, de forma assíncrona e numa transação nova. Se o pedagógico falhar, a matrícula já está salva e não é desfeita.

E o evento não se perde porque, com o starter-jdbc, o Spring Modulith grava cada publicação numa tabela (event_publication) na mesma transação da matrícula, uma linha por ouvinte. A linha só é marcada como concluída quando o ouvinte termina com sucesso.

application.properties
spring.modulith.events.jdbc.schema-initialization.enabled=true
spring.modulith.events.republish-outstanding-events-on-restart=true

Se o processo cair no meio, na volta as publicações incompletas são reenviadas. Para reprocessar sem reiniciar, existe a interface IncompleteEventPublications:

escola/infra/ReprocessaEventos.java
package escola.infra;
import java.time.Duration;
import org.springframework.modulith.events.IncompleteEventPublications;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
class ReprocessaEventos {
private final IncompleteEventPublications pendentes;
ReprocessaEventos(IncompleteEventPublications pendentes) {
this.pendentes = pendentes;
}
@Scheduled(fixedDelay = 60_000)
void reprocessar() {
pendentes.resubmitIncompletePublicationsOlderThan(Duration.ofMinutes(5));
}
}

O @Scheduled só dispara com @EnableScheduling em alguma configuração; o Spring Boot não liga o agendamento sozinho. Sem isso, a classe sobe e nunca roda.

Saindo do processo sem mudar o código

Quando um evento precisa sair para outro sistema, o Spring Modulith publica no broker sem mudar o publicador. Com a dependência spring-modulith-events-kafka, basta anotar o evento:

import org.springframework.modulith.events.Externalized;
@Externalized("escola.matriculas.confirmadas")
public record MatriculaConfirmada(UUID estudanteId, UUID turmaId, int anoLetivo, String nomeSocial) {}

No Spring Boot 4, o suporte a Kafka virou um módulo à parte, então entra também o spring-boot-starter-kafka. O Spring Modulith já configura o envio em JSON. No repositório, essa anotação entra na tag passo-2, junto com o segundo passo do próximo post.

A externalização passa pelo mesmo registro de publicações, então ganha a mesma garantia de não perder evento. Ela conta como mais um ouvinte: a partir daí, cada MatriculaConfirmada gera duas linhas em event_publication, uma para o pedagógico e uma para o Kafka. Isso vai ser importante no próximo post.

Python: o outbox escrito à mão no smarthome-hub#

Sem um framework que faça isso por você, o padrão fica explícito. No smarthome-hub, um comando para um dispositivo precisa ser gravado no banco e publicado no broker MQTT. O ADR 0009 descreve o dilema:

  • Publica e depois faz commit: se o commit falha, o dispositivo age sobre um comando que o hub não registrou (uma fechadura abre sem nada no log de auditoria).
  • Faz commit e depois publica: se o processo cai no meio, o comando fica gravado e nunca é enviado.

A saída é o outbox: a linha do comando e a linha da mensagem entram na mesma transação, e um relay no worker publica depois.

O coração do relay é uma query de reivindicação. Simplificada:

relay (simplificado)
SELECT id, topic, qos, payload, headers
FROM outbox.messages
WHERE published_at IS NULL
AND next_attempt_at <= now()
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;

FOR UPDATE SKIP LOCKED deixa vários workers rodarem lado a lado sem publicar a mesma linha duas vezes: cada um pega um lote que os outros não estão segurando. A linha só é marcada como publicada depois do PUBACK do broker, dentro da mesma transação que a reivindicou.

Dois detalhes do ADR que valem para qualquer outbox:

  • Acordar sem polling agressivo. Um trigger dispara NOTIFY outbox, que o Postgres entrega no commit, então o relay publica em milissegundos. Um polling de 1 segundo cobre notificações perdidas.
  • Manter a ordem com o broker fora. Se uma publicação falha, o relay faz backoff exponencial e para o lote, em vez de pular para a próxima linha.

Testando#

Em Java, o Spring Modulith tem suporte a teste por módulo. Por padrão (BootstrapMode.STANDALONE), o @ApplicationModuleTest sobe só o módulo testado; os beans de outros módulos precisam ser mocks. Como a matrícula chama turmas de verdade aqui, o teste pede DIRECT_DEPENDENCIES, que sobe também as dependências diretas. O Scenario espera o evento:

src/test/java/escola/matricula/MatriculaTest.java
@ApplicationModuleTest(mode = BootstrapMode.DIRECT_DEPENDENCIES)
@Import(TestcontainersConfiguration.class) // PostgreSQL de verdade, nada de H2
class MatriculaTest {
static final UUID TURMA_COM_VAGA = UUID.randomUUID();
@Autowired MatriculaService service;
@Autowired JdbcClient jdbc;
@BeforeEach
void abreTurmaComVaga() {
jdbc.sql("""
INSERT INTO turmas.turmas (id, ano_letivo, capacidade) VALUES (:id, 2027, 1000)
ON CONFLICT (id) DO NOTHING
""")
.param("id", TURMA_COM_VAGA)
.update();
}
@Test
void publicaMatriculaConfirmada(Scenario scenario) {
var pedido = new NovaMatricula(UUID.randomUUID(), TURMA_COM_VAGA, 2027, "Ana");
scenario.stimulate(() -> service.matricular(pedido))
.andWaitForEventOfType(MatriculaConfirmada.class)
.matching(ev -> ev.estudanteId().equals(pedido.estudanteId()))
.toArrive();
}
}
Terminal window
./gradlew test

O repositório tem mais três testes que valem olhar: um que mostra a vaga voltando quando a matrícula falha (reserva e save estão na mesma transação), um que confere que a publicação de cada ouvinte é marcada como concluída, e o de entregas simultâneas do ouvinte do pedagógico.

No smarthome-hub, os testes de integração do outbox cobrem os casos difíceis com banco e broker reais (Testcontainers): rollback, ordem, relays lado a lado sem publicar duas vezes (o teste falha sem o SKIP LOCKED) e queda do broker com backoff.

Terminal window
make test-it

Armadilhas e trade-offs#

Resumo#

SituaçãoUseGarantia
Preciso da resposta agoraChamada pela fachadaMesma transação, se quiser
Outros módulos precisam saber que algo aconteceuEvento in-processEventual
O evento não pode se perder (Java)@ApplicationModuleListener + registro JDBCPelo menos uma vez
O evento não pode se perder (sem framework)Transactional outbox + relayPelo menos uma vez
O evento precisa sair do processo@Externalized ou o mesmo relayPelo menos uma vez
Preciso de dado de outro móduloFachada ou cópia local via eventoImediata ou eventual

Conclusão#

Dentro de um processo, a tentação é tratar comunicação como detalhe. Mas as decisões que você toma aqui (chamada ou evento, schema compartilhado ou não, evento persistido ou não) são exatamente as que decidem se extrair um módulo vai custar uma semana ou um trimestre.

No último post da série, faço essa extração: quando ela vale a pena, quando não vale, e como fazer passo a passo sem big bang.

Código no GitHub#

O código deste artigo está no GitHubdev-danilocordeiro/monolito-modular

Referências

  1. Spring Modulith — Working with Application Events docs.spring.io
  2. Transactional Outbox — microservices.io microservices.io
  3. PostgreSQL — SELECT ... FOR UPDATE SKIP LOCKED postgresql.org
  4. smarthome-hub — ADR 0009 (outbox de comandos) github.com

Encontrou um erro? Me avise por e-mail