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

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

Quando (e como) extrair um módulo para um serviço

Os sinais reais para sair do monolito modular, os falsos, e uma extração passo a passo sem big bang: fachada, adaptador remoto, eventos no broker e o schema saindo por último.

Publicado em · Read in English

TL;DR

  • Extraia por necessidade operacional ou organizacional (cadência de deploy, isolamento de falha ou de dados, requisito técnico diferente), não porque o código “ficou grande”.
  • Se os posts anteriores da série foram seguidos, a extração é quase mecânica: a fachada vira cliente, os eventos vão para o broker e o schema muda de banco.
  • Faça em passos reversíveis atrás de uma flag. O schema é o último a sair, e a volta (serviço de novo para o monolito) também é uma opção legítima.

Introdução#

Esta é a última parte da série sobre monolito modular. Separamos monorepo de monolito, traçamos fronteiras com DDD estratégico, garantimos as fronteiras no CI e resolvemos a comunicação com eventos e outbox.

Tudo isso tinha um objetivo implícito: deixar barato mudar de ideia. Se um módulo precisar virar serviço, a extração deveria ser uma sequência de passos pequenos, não um projeto de seis meses.

Mas antes do como, o quando. A maior parte das extrações que vi não deveria ter acontecido, e algumas empresas fizeram o caminho contrário: o time do Prime Video publicou em 2023 como juntou um sistema de monitoramento distribuído num processo só e reduziu o custo em 90%.

Pré-requisitos#

  • Os posts anteriores da série, principalmente o de comunicação entre módulos
  • Familiaridade com Spring Boot e com Kafka (ou outro broker)
  • JDK 21+ e Docker para rodar o exemplo

Conceito#

Sinais reais#

SinalExemplo
Cadência de deploy diferenteUm time faz deploy dez vezes por dia e outro uma vez por semana, e eles se bloqueiam
Isolamento de falha que processo separado não resolveUm vazamento de memória num módulo derruba os outros, mesmo com entrypoints separados
Isolamento de dados ou complianceDados de pagamento precisam ficar num perímetro auditado (PCI), separado do resto
Requisito técnico diferenteUm módulo precisa de GPU, de outra linguagem ou de outro runtime
Times e ConwayUm time inteiro é dono do módulo e o resto da base é ruído para ele

Sinais falsos#

  • “O código está grande.” Tamanho se resolve com módulos, não com rede.
  • “Precisa escalar.” Muitas vezes, vários entrypoints sobre o mesmo código resolvem, como fez o smarthome-hub com api, ingestor e worker. Escalar processo não exige separar o domínio.
  • “Microsserviço é o padrão.” Não é, e o post de abertura da série discute isso.
  • “Vamos reescrever melhor.” Extração não é reescrita. Misturar as duas multiplica o risco.

O que a série já deixou pronto#

PreparaçãoPostO que vira na extração
Fachada pública explícitaFronteiras no CIInterface do cliente remoto
Eventos com registro de publicaçõesComunicaçãoEventos externalizados no broker
Um schema por módulo, sem FKComunicaçãoBanco do novo serviço
Cópia local de dados de outros módulosComunicaçãoNada muda

Se algum desses itens não existe, faça antes de extrair, ainda dentro do monolito. É bem mais barato.

Os passos#

É a ideia do Strangler Fig do Fowler aplicada a um módulo: o novo cresce em volta do antigo até o antigo poder ser removido. Cada passo é reversível até o 5.

Mão na massa#

Vou extrair o módulo turmas da plataforma escolar. Ele é um bom candidato didático: a matrícula chama reservarVaga de forma síncrona, e outros módulos consomem eventos dele.

Terminal window
git clone https://github.com/dev-danilocordeiro/monolito-modular
cd monolito-modular/java
git checkout passo-0 # ponto de partida; cada passo abaixo tem a sua tag
docker compose up -d postgres kafka

Cada passo é um commit com tag, de passo-1 a passo-6, e o main termina no passo 6. Para ver o que um passo mudou, git diff passo-3 passo-4 -- java/. O CI roda os testes de todas as tags, inclusive os de ponta a ponta.

Passo 1: a fachada vira uma porta#

Hoje a matrícula depende da classe TurmasService. Troque por uma interface no pacote público de turmas, e a implementação atual vira o adaptador local:

escola/turmas/TurmasApi.java
package escola.turmas;
import java.util.UUID;
public interface TurmasApi {
void reservarVaga(UUID turmaId);
void liberarVaga(UUID turmaId);
}
escola/turmas/internal/TurmasLocal.java
package escola.turmas.internal;
@Service
@ConditionalOnProperty(name = "escola.turmas.modo", havingValue = "local", matchIfMissing = true)
class TurmasLocal implements TurmasApi {
// a implementação que já existia, sem mudança
}

A matrícula passa a injetar TurmasApi. Nada muda em produção. Rode a suíte e faça deploy só disso (tag passo-1).

Passo 2: eventos para o broker#

Os eventos de turmas já passam pelo registro de publicações. Externalizar é uma anotação:

escola/turmas/VagasEsgotadas.java
@Externalized("escola.turmas.vagas-esgotadas")
public record VagasEsgotadas(UUID turmaId, int anoLetivo) {}

Entram as dependências spring-modulith-events-kafka e spring-boot-starter-kafka, e a MatriculaConfirmada ganha o @Externalized do post anterior. Os ouvintes dentro do monolito continuam recebendo pelo caminho in-process. A diferença é que agora o evento também está no Kafka, e o futuro serviço pode publicar no mesmo tópico (tag passo-2).

Passo 3: o serviço novo, com o mesmo código#

Crie um app novo no monorepo que contém só o módulo turmas e expõe a fachada por HTTP. O código de domínio é copiado (ou movido para um módulo Gradle compartilhado durante a transição), não reescrito. No repositório ele é copiado, para o diff de cada passo ficar fácil de ler:

servico-turmas/.../TurmasController.java
@RestController
@RequestMapping("/turmas")
class TurmasController {
private final TurmasApi turmas;
TurmasController(TurmasApi turmas) {
this.turmas = turmas;
}
@PostMapping("/{turmaId}/reservas")
ResponseEntity<Void> reservar(@PathVariable UUID turmaId,
@RequestHeader("Idempotency-Key") String chave) {
turmas.reservarVaga(turmaId); // TODO: deduplicar pela chave
return ResponseEntity.noContent().build();
}
}

O serviço sobe apontando para o mesmo schema turmas do banco do monolito. Ainda não é hora de mover dados. O TODO do controller fica para o passo 4 (tag passo-3).

Dois detalhes que só aparecem quando os dois apps dividem o banco:

  • O registro de publicações do serviço precisa de outro lugar. Com o padrão, os dois apps usariam a mesma public.event_publication, e um reenviaria as publicações do outro ao reiniciar. Com spring.modulith.events.jdbc.schema=turmas, a tabela do serviço mora no schema dele e sai junto no passo 5.
  • As migrations precisam rodar antes do registro. Se o Spring Modulith criar a turmas.event_publication antes do Flyway, o Flyway encontra o schema não vazio e sem histórico, e para. O spring-boot-starter-flyway registra o Flyway como inicializador do banco, e o Spring Boot o executa antes do inicializador do Modulith.

Passo 4: o adaptador remoto atrás de flag#

Antes de escrever o adaptador, a reserva ganha identidade. Dentro do monolito ela não precisava: reserva e matrícula estavam na mesma transação. Do outro lado da rede, o serviço precisa reconhecer uma repetição, e para isso a porta passa a receber o id da reserva:

escola/turmas/TurmasApi.java
public interface TurmasApi {
void reservarVaga(UUID turmaId, UUID reservaId);
void liberarVaga(UUID turmaId, UUID reservaId);
}

Quem chama decide o id, e ele precisa ser estável por tentativa de matrícula. A matrícula já tem um id antes de ser salva, então ele serve:

escola/matricula/MatriculaService.java (trecho)
var matricula = Matricula.confirmada(pedido);
// O id da matrícula identifica esta tentativa e vira a chave da reserva:
// um retry da mesma tentativa nunca reserva duas vagas.
turmas.reservarVaga(pedido.turmaId(), matricula.id());
matriculas.save(matricula);

No monolito, uma segunda implementação de TurmasApi chama o serviço:

escola/turmas/internal/TurmasRemoto.java
package escola.turmas.internal;
@Service
@ConditionalOnProperty(name = "escola.turmas.modo", havingValue = "remoto")
class TurmasRemoto implements TurmasApi {
private static final Logger log = LoggerFactory.getLogger(TurmasRemoto.class);
private final RestClient http;
// Timeout ou conexão recusada: não dá para saber se o serviço processou, então
// repete. É seguro porque a repetição leva a mesma chave.
private final RetryTemplate retry = new RetryTemplate(RetryPolicy.builder()
.includes(ResourceAccessException.class)
.maxRetries(2)
.delay(Duration.ofMillis(200))
.build());
TurmasRemoto(RestClient.Builder builder, @Value("${escola.turmas.url}") String url) {
this.http = builder.baseUrl(url).build();
}
@Override
public void reservarVaga(UUID turmaId, UUID reservaId) {
// Registrada antes da chamada: se todas as tentativas estourarem o tempo, não dá
// para saber se o serviço reservou. Liberar com a mesma chave cobre os dois casos.
liberarSeATransacaoDesfizer(turmaId, reservaId);
retry.invoke(() -> http.post()
.uri("/turmas/{id}/reservas", turmaId)
.header("Idempotency-Key", reservaId.toString()) // estável: vem da tentativa de matrícula
.retrieve()
.onStatus(status -> status.isSameCodeAs(HttpStatus.CONFLICT), (req, res) -> { throw new TurmaSemVaga(turmaId); })
.onStatus(status -> status.isSameCodeAs(HttpStatus.NOT_FOUND), (req, res) -> { throw new TurmaNaoEncontrada(turmaId); })
.onStatus(status -> status.isSameCodeAs(HttpStatus.UNPROCESSABLE_CONTENT),
(req, res) -> { throw new ReservaConflitante(reservaId, "recusada pelo serviço de turmas"); })
.toBodilessEntity());
}
@Override
public void liberarVaga(UUID turmaId, UUID reservaId) {
retry.invoke(() -> http.delete()
.uri("/turmas/{id}/reservas/{reserva}", turmaId, reservaId)
.retrieve()
.toBodilessEntity());
}
/**
* Antes da extração, reserva e matrícula estavam na mesma transação: se a matrícula
* falhasse, a vaga voltava junto. A chamada remota não participa da transação local,
* então a devolução vira uma compensação explícita, disparada quando ela desfaz.
*/
private void liberarSeATransacaoDesfizer(UUID turmaId, UUID reservaId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status != STATUS_ROLLED_BACK) {
return;
}
try {
liberarVaga(turmaId, reservaId);
} catch (RuntimeException erro) {
// Sem retry depois daqui: a vaga fica presa até alguém liberar com a mesma chave.
log.atError()
.addKeyValue("turmaId", turmaId)
.addKeyValue("reservaId", reservaId)
.setCause(erro)
.log("compensação falhou: vaga reservada para uma matrícula que não existe");
}
}
});
}
}
application.properties
escola.turmas.modo=remoto
escola.turmas.url=http://servico-turmas:8080
spring.http.clients.connect-timeout=1s
spring.http.clients.read-timeout=2s

Trocar modo de volta para local é o rollback. Como os dois lados usam o mesmo schema, não há dado para sincronizar.

Do lado do serviço, o TODO do passo 3 vira código. A chave do cabeçalho é o id da reserva, e a liberação vira um DELETE no recurso da reserva:

servico-turmas/.../TurmasController.java (trecho)
@PostMapping("/{turmaId}/reservas")
ResponseEntity<Void> reservar(@PathVariable UUID turmaId,
@RequestHeader("Idempotency-Key") UUID chave) {
turmas.reservarVaga(turmaId, chave); // a chave é o id da reserva: repetir não reserva de novo
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{turmaId}/reservas/{reservaId}")
ResponseEntity<Void> liberar(@PathVariable UUID turmaId, @PathVariable UUID reservaId) {
turmas.liberarVaga(turmaId, reservaId);
return ResponseEntity.noContent().build();
}

A deduplicação mora no banco, numa tabela turmas.reservas com o id como chave primária. Quem decide se a reserva é nova é o INSERT ... ON CONFLICT (id) DO NOTHING, então duas chamadas simultâneas com a mesma chave ocupam uma vaga só:

escola/turmas/internal/TurmasLocal.java (trecho)
@Override
@Transactional
public void reservarVaga(UUID turmaId, UUID reservaId) {
exigeTurma(turmaId);
if (!turmas.registrar(new Reserva(reservaId, turmaId, Situacao.RESERVADA))) {
confereRepeticao(reservaId, turmaId, Situacao.RESERVADA); // mesma chave: a vaga já foi reservada
return;
}
// Sem vaga, a exceção desfaz a transação e a reserva gravada acima vai junto.
Turma turma = turmas.ocuparVaga(turmaId).orElseThrow(() -> new TurmaSemVaga(turmaId));
if (turma.lotada()) {
eventos.publishEvent(new VagasEsgotadas(turma.id(), turma.anoLetivo()));
}
}
@Override
@Transactional
public void liberarVaga(UUID turmaId, UUID reservaId) {
exigeTurma(turmaId);
if (turmas.registrar(new Reserva(reservaId, turmaId, Situacao.LIBERADA))) {
return; // a reserva nunca chegou: fica registrada como liberada, e se chegar depois é recusada
}
if (turmas.marcarLiberada(reservaId)) {
turmas.desocuparVaga(turmaId);
return;
}
confereRepeticao(reservaId, turmaId, Situacao.LIBERADA); // mesma chave: a vaga já foi devolvida
}

Dois casos de borda valem o código extra. A compensação pode chegar antes da reserva que ela desfaz (a reserva estava presa na rede); por isso liberar uma reserva desconhecida grava a reserva como liberada, e a reserva atrasada é recusada. E a mesma chave usada para outra turma é erro de quem chama: o serviço responde 422, nunca um sucesso silencioso.

Ainda falta uma peça para ficar robusto: se a própria compensação falhar depois das tentativas, ela vira só um log de erro, e a vaga fica presa. A versão completa grava a compensação pendente (no registro de publicações ou num outbox) para tentar de novo depois.

É neste passo que aparecem todos os problemas que o monolito escondia: timeout, retry, idempotência, consistência entre dois sistemas (tag passo-4). O post sobre idempotência em APIs de pagamento entra em detalhe nesse assunto.

Passo 5: o schema muda de banco#

Só depois de o serviço estar estável em produção. Como não há FK entre schemas e ninguém mais lê turmas.* direto (o lint e a revisão garantiram isso), o schema pode ir para um banco próprio:

  1. Crie o banco do serviço e replique o schema turmas (replicação lógica do Postgres funciona bem para isso).
  2. Numa janela curta, pare as escritas, confirme que a réplica alcançou, aponte o serviço para o banco novo.
  3. Leve junto as migrations do schema; elas passam a ser do serviço.
no banco do monolito
-- antes de remover, confirme que ninguém mais consulta o schema
SELECT schemaname, relname, seq_scan, idx_scan
FROM pg_stat_user_tables
WHERE schemaname = 'turmas';

Se os contadores continuarem subindo depois do corte, algum código ainda lê o schema por fora da fachada.

No repositório, o scripts/mover-schema-turmas.sh faz os três passos com o compose rodando. O coração é uma publicação na origem e uma assinatura no destino:

-- no banco do monolito (precisa de wal_level=logical; FOR TABLES IN SCHEMA é do PostgreSQL 15+)
CREATE PUBLICATION turmas_para_servico FOR TABLES IN SCHEMA turmas;
-- no banco do serviço, depois de copiar a estrutura com pg_dump --schema-only --schema=turmas
CREATE SUBSCRIPTION turmas_do_monolito
CONNECTION 'host=postgres port=5432 dbname=escola user=escola password=escola'
PUBLICATION turmas_para_servico;

O histórico do Flyway e a event_publication do serviço estão no schema, então vão junto, e o serviço sobe no banco novo sem reaplicar nada. A replicação lógica não copia valores de sequência; aqui não há nenhuma, porque todos os ids são UUID. Se houver no seu caso, ajuste com setval antes de liberar as escritas.

Depois do corte, o modo local ainda existe no código, mas leria a cópia antiga do schema. Por isso o padrão do monolito passa a ser remoto, e voltar deixou de ser só trocar a flag (tag passo-5).

Passo 6: remover o módulo do monolito#

Apague a implementação local de escola/turmas/internal (TurmasLocal, o repositório e as migrations de db/turmas) e os testes dela, que passam a ser do serviço. Fica a porta TurmasApi, os eventos e exceções públicos e o TurmasRemoto, que continua em internal e perde o @ConditionalOnProperty, porque não há mais alternativa. Se usa Spring Modulith, o verify() continua garantindo que ninguém volte a depender de algo interno que não existe mais (tag passo-6).

O schema antigo no banco do monolito sai por último, com um DROP SCHEMA turmas CASCADE feito à mão, depois de o pg_stat_user_tables confirmar que ninguém o lê.

Testando#

Cada passo tem um teste de saída:

PassoPronto quando
1Suíte verde com modo=local; nenhuma classe fora de turmas importa TurmasLocal
2Eventos aparecem no tópico do Kafka; ouvintes internos seguem funcionando
3Serviço responde aos mesmos testes de contrato da fachada
4Em produção com modo=remoto, sem aumento de erro ou latência relevante; rollback testado
5pg_stat_user_tables sem acesso ao schema antigo
6Código removido, verify() verde
Terminal window
git checkout passo-4
./gradlew test
docker compose up -d postgres kafka servico-turmas
ESCOLA_TURMAS_MODO=remoto ./gradlew bootRun
./e2e/passo-4.sh # alternativa: sobe tudo sozinho, matricula pelo serviço e confere a compensação

O serviço fica em localhost:8081; se a porta estiver ocupada, troque com SERVICO_TURMAS_PORTA. Os passos 5 e 6 têm os seus e2e/passo-*.sh; o do passo 5 roda o script de replicação e confere que nada se perdeu no corte.

Armadilhas e trade-offs#

Resumo#

PassoMudaReversível?
1. Fachada vira portaInterface + adaptador localSim
2. Eventos no brokerAnotação nos eventosSim
3. Serviço novoApp novo, mesmo schemaSim
4. Adaptador remotoFlag modo=remotoSim, troca a flag
5. Schema muda de bancoDados e migrationsDifícil
6. Remove do monolitoCódigoSó com o histórico do Git

Conclusão#

A série começou dizendo que monorepo, monolito e microsserviço são decisões diferentes, e termina mostrando por que vale começar no meio: um monolito modular com fronteiras garantidas mantém as opções abertas. Se você nunca precisar extrair, tem um sistema simples de operar. Se precisar, a extração vira uma sequência de passos pequenos.

O que essa série não cobriu é o que acontece depois: chamadas entre serviços que precisam ser idempotentes, eventos que podem chegar duplicados e consistência entre sistemas. É exatamente o assunto da série Sistemas Financeiros na Prática, que começa com idempotência em APIs de pagamento.

Código no GitHub#

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

Referências

  1. Monolith to Microservices — Sam Newman samnewman.io
  2. StranglerFigApplication — Martin Fowler martinfowler.com
  3. Spring Modulith — Externalizing events docs.spring.io
  4. Prime Video — Scaling up the audio/video monitoring service and reducing costs by 90% web.archive.org

Encontrou um erro? Me avise por e-mail