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?#
| Pergunta | Chamada síncrona | Evento |
|---|---|---|
| Quem chama precisa da resposta para continuar? | Sim | Não |
| A operação pode falhar e a falha deve voltar a quem chamou? | Sim | Não |
| Quantos módulos se interessam? | Um | Vários, e pode crescer |
| Quem depende de quem? | Chamador depende do chamado | Ouvinte depende do publicador |
| Consistência | Imediata (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:
- Salva a matrícula.
- Publica
MatriculaConfirmada. - 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#
git clone https://github.com/dev-danilocordeiro/monolito-modularcd monolito-modular/javagit checkout passo-0 # o estado deste post; o main já está no fim do post seguintedocker compose up -d postgresdependencies { 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:
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;
@Servicepublic 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; }}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:
package escola.pedagogico;
import escola.matricula.MatriculaConfirmada;import org.springframework.modulith.events.ApplicationModuleListener;import org.springframework.stereotype.Component;
@Componentclass 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.
spring.modulith.events.jdbc.schema-initialization.enabled=truespring.modulith.events.republish-outstanding-events-on-restart=trueSe o processo cair no meio, na volta as publicações incompletas são reenviadas. Para reprocessar sem reiniciar, existe a interface IncompleteEventPublications:
package escola.infra;
import java.time.Duration;import org.springframework.modulith.events.IncompleteEventPublications;import org.springframework.scheduling.annotation.Scheduled;import org.springframework.stereotype.Component;
@Componentclass 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:
SELECT id, topic, qos, payload, headersFROM outbox.messagesWHERE published_at IS NULL AND next_attempt_at <= now()ORDER BY idLIMIT 100FOR 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:
@ApplicationModuleTest(mode = BootstrapMode.DIRECT_DEPENDENCIES)@Import(TestcontainersConfiguration.class) // PostgreSQL de verdade, nada de H2class 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(); }}./gradlew testO 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.
make test-itArmadilhas e trade-offs#
Resumo#
| Situação | Use | Garantia |
|---|---|---|
| Preciso da resposta agora | Chamada pela fachada | Mesma transação, se quiser |
| Outros módulos precisam saber que algo aconteceu | Evento in-process | Eventual |
| O evento não pode se perder (Java) | @ApplicationModuleListener + registro JDBC | Pelo menos uma vez |
| O evento não pode se perder (sem framework) | Transactional outbox + relay | Pelo menos uma vez |
| O evento precisa sair do processo | @Externalized ou o mesmo relay | Pelo menos uma vez |
| Preciso de dado de outro módulo | Fachada ou cópia local via evento | Imediata 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-modularReferências
- Spring Modulith — Working with Application Events docs.spring.io
- Transactional Outbox — microservices.io microservices.io
- PostgreSQL — SELECT ... FOR UPDATE SKIP LOCKED postgresql.org
- smarthome-hub — ADR 0009 (outbox de comandos) github.com
Encontrou um erro? Me avise por e-mail