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
Neste artigo
- 1Introdução
- 2Pré-requisitos
- 3Conceito
- 3.1Sinais reais
- 3.2Sinais falsos
- 3.3O que a série já deixou pronto
- 3.4Os passos
- 4Mão na massa
- 4.1Passo 1: a fachada vira uma porta
- 4.2Passo 2: eventos para o broker
- 4.3Passo 3: o serviço novo, com o mesmo código
- 4.4Passo 4: o adaptador remoto atrás de flag
- 4.5Passo 5: o schema muda de banco
- 4.6Passo 6: remover o módulo do monolito
- 5Testando
- 6Armadilhas e trade-offs
- 7Resumo
- 8Conclusão
- 9Código no GitHub
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#
| Sinal | Exemplo |
|---|---|
| Cadência de deploy diferente | Um 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 resolve | Um vazamento de memória num módulo derruba os outros, mesmo com entrypoints separados |
| Isolamento de dados ou compliance | Dados de pagamento precisam ficar num perímetro auditado (PCI), separado do resto |
| Requisito técnico diferente | Um módulo precisa de GPU, de outra linguagem ou de outro runtime |
| Times e Conway | Um 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,ingestoreworker. 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ção | Post | O que vira na extração |
|---|---|---|
| Fachada pública explícita | Fronteiras no CI | Interface do cliente remoto |
| Eventos com registro de publicações | Comunicação | Eventos externalizados no broker |
| Um schema por módulo, sem FK | Comunicação | Banco do novo serviço |
| Cópia local de dados de outros módulos | Comunicação | Nada 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.
git clone https://github.com/dev-danilocordeiro/monolito-modularcd monolito-modular/javagit checkout passo-0 # ponto de partida; cada passo abaixo tem a sua tagdocker compose up -d postgres kafkaCada 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:
package escola.turmas;
import java.util.UUID;
public interface TurmasApi { void reservarVaga(UUID turmaId); void liberarVaga(UUID turmaId);}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:
@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:
@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. Comspring.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_publicationantes do Flyway, o Flyway encontra o schema não vazio e sem histórico, e para. Ospring-boot-starter-flywayregistra 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:
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:
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:
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"); } } }); }}escola.turmas.modo=remotoescola.turmas.url=http://servico-turmas:8080spring.http.clients.connect-timeout=1sspring.http.clients.read-timeout=2sTrocar 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:
@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ó:
@Override@Transactionalpublic 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@Transactionalpublic 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:
- Crie o banco do serviço e replique o schema
turmas(replicação lógica do Postgres funciona bem para isso). - Numa janela curta, pare as escritas, confirme que a réplica alcançou, aponte o serviço para o banco novo.
- Leve junto as migrations do schema; elas passam a ser do serviço.
-- antes de remover, confirme que ninguém mais consulta o schemaSELECT schemaname, relname, seq_scan, idx_scanFROM pg_stat_user_tablesWHERE 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=turmasCREATE 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:
| Passo | Pronto quando |
|---|---|
| 1 | Suíte verde com modo=local; nenhuma classe fora de turmas importa TurmasLocal |
| 2 | Eventos aparecem no tópico do Kafka; ouvintes internos seguem funcionando |
| 3 | Serviço responde aos mesmos testes de contrato da fachada |
| 4 | Em produção com modo=remoto, sem aumento de erro ou latência relevante; rollback testado |
| 5 | pg_stat_user_tables sem acesso ao schema antigo |
| 6 | Código removido, verify() verde |
git checkout passo-4./gradlew testdocker compose up -d postgres kafka servico-turmasESCOLA_TURMAS_MODO=remoto ./gradlew bootRun./e2e/passo-4.sh # alternativa: sobe tudo sozinho, matricula pelo serviço e confere a compensaçãoO 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#
| Passo | Muda | Reversível? |
|---|---|---|
| 1. Fachada vira porta | Interface + adaptador local | Sim |
| 2. Eventos no broker | Anotação nos eventos | Sim |
| 3. Serviço novo | App novo, mesmo schema | Sim |
| 4. Adaptador remoto | Flag modo=remoto | Sim, troca a flag |
| 5. Schema muda de banco | Dados e migrations | Difícil |
| 6. Remove do monolito | Código | Só 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-modularReferências
- Monolith to Microservices — Sam Newman samnewman.io
- StranglerFigApplication — Martin Fowler martinfowler.com
- Spring Modulith — Externalizing events docs.spring.io
- 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