When (and how) to extract a module into a service
The real signals for leaving the modular monolith, the false ones, and a step-by-step extraction with no big bang: facade, remote adapter, events on the broker and the schema leaving last.
Published · Ler em português
- #architecture
- #modular-monolith
- #microservices
- #Java & Spring
In this post
- 1Introduction
- 2Prerequisites
- 3Concept
- 3.1Real signals
- 3.2False signals
- 3.3What the series already set up
- 3.4The steps
- 4Hands-on
- 4.1Step 1: the facade becomes a port
- 4.2Step 2: events to the broker
- 4.3Step 3: the new service, with the same code
- 4.4Step 4: the remote adapter behind a flag
- 4.5Step 5: the schema moves to another database
- 4.6Step 6: remove the module from the monolith
- 5Testing
- 6Pitfalls and trade-offs
- 7Summary
- 8Conclusion
- 9Code on GitHub
TL;DR
- Extract out of operational or organizational need (deploy cadence, fault or data isolation, a different technical requirement), not because the code “got big”.
- If you followed the earlier posts in the series, the extraction is almost mechanical: the facade becomes a client, the events go to the broker and the schema moves to another database.
- Do it in reversible steps behind a flag. The schema leaves last, and going back (service into the monolith again) is a legitimate option too.
Introduction#
This is the last part of the series on the modular monolith. We pulled apart monorepo and monolith, drew boundaries with strategic DDD, enforced those boundaries in CI and solved communication with events and an outbox.
All of that had an implicit goal: make changing your mind cheap. If a module needs to become a service, the extraction should be a sequence of small steps, not a six-month project.
But before the how, the when. Most of the extractions I’ve seen shouldn’t have happened, and some companies went the opposite way: in 2023 the Prime Video team published how they merged a distributed monitoring system into a single process and cut its cost by 90%.
Prerequisites#
- The previous posts in the series, especially the one on communication between modules
- Familiarity with Spring Boot and Kafka (or another broker)
- JDK 21+ and Docker to run the example
Concept#
Real signals#
| Signal | Example |
|---|---|
| Different deploy cadence | One team deploys ten times a day and another once a week, and they block each other |
| Fault isolation that a separate process doesn’t solve | A memory leak in one module takes the others down, even with separate entrypoints |
| Data isolation or compliance | Payment data has to stay inside an audited perimeter (PCI), apart from everything else |
| Different technical requirement | A module needs a GPU, another language or another runtime |
| Teams and Conway | A whole team owns the module and the rest of the codebase is noise to them |
False signals#
- “The code is big.” Size is solved with modules, not with a network.
- “It needs to scale.” Often several entrypoints over the same code are enough, the way smarthome-hub does with
api,ingestorandworker. Scaling a process doesn’t require splitting the domain. - “Microservices are the standard.” They aren’t, and the series’ opening post discusses that.
- “Let’s rewrite it better.” An extraction is not a rewrite. Mixing the two multiplies the risk.
What the series already set up#
| Preparation | Post | What it becomes in the extraction |
|---|---|---|
| Explicit public facade | Boundaries in CI | The remote client’s interface |
| Events with a publication registry | Communication | Events externalized to the broker |
| One schema per module, no FKs | Communication | The new service’s database |
| Local copy of other modules’ data | Communication | Nothing changes |
If any of these doesn’t exist, do it before extracting, still inside the monolith. It’s much cheaper.
The steps#
It’s Fowler’s Strangler Fig idea applied to a module: the new one grows around the old one until the old one can be removed. Every step up to 5 is reversible.
Hands-on#
I’m going to extract the turmas (“classes”) module from the school platform. It’s a good teaching candidate: enrollment (matricula) calls reservarVaga (“reserve a seat”) synchronously, and other modules consume its events.
git clone https://github.com/dev-danilocordeiro/monolito-modularcd monolito-modular/javagit checkout passo-0 # starting point; each step below has its own tagdocker compose up -d postgres kafkaEach step is a tagged commit, from passo-1 to passo-6 (“step 1” to “step 6”), and main ends at step 6. To see what a step changed, git diff passo-3 passo-4 -- java/. CI runs the tests of every tag, including the end-to-end ones.
Step 1: the facade becomes a port#
Today enrollment depends on the TurmasService class. Replace it with an interface in the public package of turmas, and the current implementation becomes the local adapter:
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 { // the implementation that already existed, unchanged}Enrollment now injects TurmasApi. Nothing changes in production. Run the suite and deploy just that (tag passo-1).
Step 2: events to the broker#
The events from turmas already go through the publication registry. Externalizing them is one annotation:
@Externalized("escola.turmas.vagas-esgotadas")public record VagasEsgotadas(UUID turmaId, int anoLetivo) {}The spring-modulith-events-kafka and spring-boot-starter-kafka dependencies come in, and MatriculaConfirmada gets the @Externalized from the previous post. Listeners inside the monolith keep receiving through the in-process path. The difference is that now the event is also on Kafka, and the future service can publish to the same topic (tag passo-2).
Step 3: the new service, with the same code#
Create a new app in the monorepo that contains only the turmas module and exposes the facade over HTTP. The domain code is copied (or moved to a shared Gradle module during the transition), not rewritten. In the repository it’s copied, so each step’s diff stays easy to read:
@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: deduplicate by key return ResponseEntity.noContent().build(); }}The service starts pointing at the same turmas schema in the monolith’s database. It’s not time to move data yet. The controller’s TODO is left for step 4 (tag passo-3).
Two details only show up when both apps share the database:
- The service’s publication registry needs a different home. With the default, both apps would use the same
public.event_publication, and one would resend the other’s publications on restart. Withspring.modulith.events.jdbc.schema=turmas, the service’s table lives in its own schema and leaves with it in step 5. - Migrations have to run before the registry. If Spring Modulith creates
turmas.event_publicationbefore Flyway, Flyway finds a non-empty schema with no history and stops.spring-boot-starter-flywayregisters Flyway as a database initializer, and Spring Boot runs it before Modulith’s initializer.
Step 4: the remote adapter behind a flag#
Before writing the adapter, the reservation gets an identity. Inside the monolith it didn’t need one: reservation and enrollment were in the same transaction. On the other side of the network, the service has to recognize a repeat, and for that the port now receives the reservation id:
public interface TurmasApi { void reservarVaga(UUID turmaId, UUID reservaId); void liberarVaga(UUID turmaId, UUID reservaId);}The caller picks the id, and it has to be stable per enrollment attempt. The enrollment already has an id before it’s saved, so that one works:
var matricula = Matricula.confirmada(pedido);// The enrollment id identifies this attempt and becomes the reservation key:// a retry of the same attempt never reserves two seats.turmas.reservarVaga(pedido.turmaId(), matricula.id());matriculas.save(matricula);In the monolith, a second implementation of TurmasApi calls the service:
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 or connection refused: there's no way to know whether the service processed it, // so retry. It's safe because the retry carries the same key. 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) { // Registered before the call: if every attempt times out, there's no way to know // whether the service reserved. Releasing with the same key covers both cases. liberarSeATransacaoDesfizer(turmaId, reservaId); retry.invoke(() -> http.post() .uri("/turmas/{id}/reservas", turmaId) .header("Idempotency-Key", reservaId.toString()) // stable: comes from the enrollment attempt .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, "rejected by the turmas service"); }) .toBodilessEntity()); }
@Override public void liberarVaga(UUID turmaId, UUID reservaId) { retry.invoke(() -> http.delete() .uri("/turmas/{id}/reservas/{reserva}", turmaId, reservaId) .retrieve() .toBodilessEntity()); }
/** * Before the extraction, reservation and enrollment were in the same transaction: if the * enrollment failed, the seat came back with it. The remote call doesn't take part in the * local transaction, so giving the seat back becomes an explicit compensation, fired when * the transaction rolls back. */ 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) { // No retry past this point: the seat stays stuck until someone releases it with the same key. log.atError() .addKeyValue("turmaId", turmaId) .addKeyValue("reservaId", reservaId) .setCause(erro) .log("compensation failed: seat reserved for an enrollment that doesn't exist"); } } }); }}escola.turmas.modo=remotoescola.turmas.url=http://servico-turmas:8080spring.http.clients.connect-timeout=1sspring.http.clients.read-timeout=2sSwitching modo back to local is the rollback. Since both sides use the same schema, there’s no data to sync.
On the service side, the TODO from step 3 becomes code. The header key is the reservation id, and releasing becomes a DELETE on the reservation resource:
@PostMapping("/{turmaId}/reservas")ResponseEntity<Void> reservar(@PathVariable UUID turmaId, @RequestHeader("Idempotency-Key") UUID chave) { turmas.reservarVaga(turmaId, chave); // the key is the reservation id: repeating doesn't reserve again 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();}Deduplication lives in the database, in a turmas.reservas table with the id as primary key. What decides whether a reservation is new is the INSERT ... ON CONFLICT (id) DO NOTHING, so two concurrent calls with the same key take a single seat:
@Override@Transactionalpublic void reservarVaga(UUID turmaId, UUID reservaId) { exigeTurma(turmaId); if (!turmas.registrar(new Reserva(reservaId, turmaId, Situacao.RESERVADA))) { confereRepeticao(reservaId, turmaId, Situacao.RESERVADA); // same key: the seat was already reserved return; } // No seat left: the exception rolls back the transaction and the reservation saved above goes with it. 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; // the reservation never arrived: it's recorded as released, and if it arrives later it's rejected } if (turmas.marcarLiberada(reservaId)) { turmas.desocuparVaga(turmaId); return; } confereRepeticao(reservaId, turmaId, Situacao.LIBERADA); // same key: the seat was already given back}Two edge cases are worth the extra code. The compensation can arrive before the reservation it undoes (the reservation was stuck in the network); that’s why releasing an unknown reservation records it as released, and the late reservation is rejected. And the same key used for a different class is the caller’s error: the service answers 422, never a silent success.
One piece is still missing to make it robust: if the compensation itself fails after its retries, it becomes just an error log, and the seat stays stuck. The full version records the pending compensation (in the publication registry or in an outbox) to try again later.
This is the step where every problem the monolith was hiding shows up: timeouts, retries, idempotency, consistency across two systems (tag passo-4). The post on idempotency in payment APIs goes into this in detail.
Step 5: the schema moves to another database#
Only once the service is stable in production. Since there are no FKs across schemas and nobody else reads turmas.* directly (the linter and code review made sure of that), the schema can move to its own database:
- Create the service’s database and replicate the
turmasschema (Postgres logical replication works well for this). - In a short window, stop writes, confirm the replica caught up, point the service at the new database.
- Take the schema’s migrations along; they now belong to the service.
-- before removing it, confirm nobody queries the schema anymoreSELECT schemaname, relname, seq_scan, idx_scanFROM pg_stat_user_tablesWHERE schemaname = 'turmas';If the counters keep going up after the cutover, some code is still reading the schema around the facade.
In the repository, scripts/mover-schema-turmas.sh does the three steps with compose running. The heart of it is a publication on the source and a subscription on the target:
-- in the monolith's database (needs wal_level=logical; FOR TABLES IN SCHEMA is PostgreSQL 15+)CREATE PUBLICATION turmas_para_servico FOR TABLES IN SCHEMA turmas;
-- in the service's database, after copying the structure with 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;The Flyway history and the service’s event_publication are in the schema, so they come along, and the service starts on the new database without reapplying anything. Logical replication doesn’t copy sequence values; there are none here, because every id is a UUID. If there are in your case, fix them with setval before allowing writes again.
After the cutover, local mode still exists in the code, but it would read the old copy of the schema. That’s why the monolith’s default becomes remoto, and going back is no longer just flipping the flag (tag passo-5).
Step 6: remove the module from the monolith#
Delete the local implementation from escola/turmas/internal (TurmasLocal, the repository and the db/turmas migrations) and its tests, which now belong to the service. What stays is the TurmasApi port, the public events and exceptions, and TurmasRemoto, which remains in internal and loses its @ConditionalOnProperty, because there’s no alternative anymore. If you use Spring Modulith, verify() keeps making sure nobody goes back to depending on something internal that no longer exists (tag passo-6).
The old schema in the monolith’s database leaves last, with a DROP SCHEMA turmas CASCADE run by hand, after pg_stat_user_tables confirms nobody reads it.
Testing#
Each step has an exit test:
| Step | Done when |
|---|---|
| 1 | Green suite with modo=local; no class outside turmas imports TurmasLocal |
| 2 | Events show up on the Kafka topic; internal listeners keep working |
| 3 | The service passes the same facade contract tests |
| 4 | In production with modo=remoto, no relevant increase in errors or latency; rollback tested |
| 5 | pg_stat_user_tables shows no access to the old schema |
| 6 | Code removed, verify() green |
git checkout passo-4./gradlew testdocker compose up -d postgres kafka servico-turmasESCOLA_TURMAS_MODO=remoto ./gradlew bootRun./e2e/passo-4.sh # alternative: brings everything up, enrolls through the service and checks the compensationThe service runs on localhost:8081; if the port is taken, change it with SERVICO_TURMAS_PORTA. Steps 5 and 6 have their own e2e/passo-*.sh; the one for step 5 runs the replication script and checks that nothing was lost in the cutover.
Pitfalls and trade-offs#
Summary#
| Step | Changes | Reversible? |
|---|---|---|
| 1. Facade becomes a port | Interface + local adapter | Yes |
| 2. Events on the broker | Annotation on the events | Yes |
| 3. New service | New app, same schema | Yes |
| 4. Remote adapter | modo=remoto flag | Yes, flip the flag |
| 5. Schema moves to another database | Data and migrations | Hard |
| 6. Remove from the monolith | Code | Only with Git history |
Conclusion#
The series started by saying that monorepo, monolith and microservices are separate decisions, and it ends by showing why it pays to start in the middle: a modular monolith with enforced boundaries keeps your options open. If you never need to extract, you have a system that’s simple to run. If you do, the extraction becomes a sequence of small steps.
What this series didn’t cover is what happens afterwards: calls between services that need to be idempotent, events that can arrive twice and consistency across systems. That’s exactly the topic of the Financial Systems in Practice series, which starts with idempotency in payment APIs.
Code on GitHub#
The code for this post is on GitHubdev-danilocordeiro/monolito-modularReferences
- 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
Found a mistake? Let me know by e-mail