Migration Path: From a Monolith to a Modular Monolith
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Migration Overview and Migration Path: From a Legacy Stack to a Modern One cover getting Bookshop onto a modern stack in the first place. This page covers what to do once it is there and has simply grown: a single-codebase, single-database Spring Boot application whose modules have started to blur into each other. Turning that monolith into a modular monolith — the same one deployable, but with enforced internal boundaries between its business capabilities — is, for most teams, both the safest first step toward microservices and, just as often, the destination the migration never needs to go past.
The safest first step, and often the final destination
Every risk Benefits and Drawbacks of Microservices names for a premature microservices split — network calls where a method call used to be, distributed transactions, operating many deployables — is a risk a modular monolith simply does not take on, because there is still only one process and one database. What a modular monolith does buy is the thing that usually motivates a microservices migration in the first place: modules whose boundaries are enforced, whose internals a different module cannot reach into, and whose coupling to each other is visible and measurable instead of implicit. For a team whose actual pain is "nobody can change the Orders code without breaking something in Catalogue," a modular monolith fixes that pain directly, without paying for a single extra network hop. Many teams stop here permanently, and that is a legitimate, deliberate outcome, not a migration left unfinished — Choosing: Monolith or Microservices? already frames exactly this as one of the two reasonable steady states.
Identifying modules by business capability
The boundary a modular monolith draws is the same boundary
Strategic Design draws for a bounded context: a
business capability or subdomain, not a technical layer. "All the controllers," "all the repositories," and "all
the services" are not modules in this sense — Catalogue, Orders, Payments are. For Bookshop, the target
module map is the same five capabilities used throughout this section:
-
Catalogue — books, authors, search; read-heavy, memory-hungry.
-
Orders — cart and checkout; today’s
Orderclass is the god class this page splits below. -
Payments — charge authorization; CPU-light but must be highly reliable.
-
Shipping — fulfillment status; bursty load, integrates with third-party carriers.
-
Customers — accounts, addresses, authentication.
Before modularisation, every package can, and eventually does, reach into every other package’s internals — Catalogue code calling into Orders' repositories directly, Shipping reading Payments' entities — with no boundary stopping it. After, each module exposes only what it deliberately publishes: a small module API for synchronous calls, and domain events for everything that can happen asynchronously. The arrows in the "after" diagram are the only paths allowed to exist; anything not drawn is a boundary Spring Modulith enforces at build time.
Enforcing the boundaries
Drawing a module map on a whiteboard changes nothing by itself — the boundary has to be enforced by tooling, or it erodes back into the tangled state within a few sprints. Three mechanisms apply, chosen by stack:
-
Packages, as a starting convention.
com.example.orders,com.example.catalogue, one top-level package per module, each with its owndomain/application/infrastructure/apisub-packages following Structuring the Code's layout. This alone is only a convention — nothing stops a developer from importingcom.example.orders.domain.Orderfrom insidecom.example.catalogue— which is exactly why it needs one of the next two mechanisms behind it. -
Spring Modulith’s
@ApplicationModule, for a Spring Boot codebase, turns the package convention into an enforced rule. Spring Modulith infers one module per top-level package under the application’s main package (or an explicit@ApplicationModuleannotation on apackage-info.java), and a verification test —ApplicationModules.of(BookshopApplication.class).verify()— fails the build the moment one module’s internal package is referenced from outside it, or a module-to-module dependency does not go through that module’s declared API. -
ArchUnit, for teams that want boundary rules independent of any specific framework, or need rules Spring Modulith does not express directly — "no class in
orders.domainmay depend on any Spring Data type," for instance. ArchUnit rules run as ordinary JUnit tests, so they fail the build the same way any other broken test does, with no separate tooling to configure in CI. -
On .NET, the equivalent is one project per module (
Bookshop.Orders,Bookshop.Catalogue), with project references restricted so a module’s assembly cannot reference another module’s internal namespace — the compiler itself enforces the boundary once cross-module references are limited to each module’s own public API project, the same role Spring Modulith’s verification test plays on the JVM.
Module APIs and in-process domain events, replacing direct references and shared tables
Two patterns replace whatever direct coupling existed between modules before:
-
Module APIs replace a direct call into another module’s internals with a call to a small, deliberately published interface —
OrdersModuleApi.findOrder(orderId)rather thanorderRepository.findById(orderId)reached from Catalogue. The module that owns the data still owns it fully; every other module reaches it only through the shape that module chose to expose. -
In-process domain events replace the cases where a direct call would couple two modules synchronously for no real reason. Spring Modulith’s
@ApplicationModuleListener(Domain Events covers the mechanism in full) lets the Orders module publishOrderPlacedand the Shipping module react to it without either module holding a reference to the other’s classes — and because Spring Modulith backs its listener with an event publication registry living in the same single database the modular monolith already shares, the event’s delivery gets the same reliability Transactional Outbox builds for a fully distributed system, at none of that pattern’s cross-database complexity: publishing the event and saving the aggregate’s own state changes commit in the same local transaction, because it is still, underneath, one database.
Shared tables go the same way as shared classes: if Catalogue and Orders both currently read and write the same
inventory_counts table directly, one module has to become that table’s sole owner, exposing what the other
needs through its module API or its events instead. Giving each module its own schema, or at minimum its own
table-name prefix, inside the one shared database makes this ownership visible at the database level, not just
in code — and it is what makes a later split into a truly separate database, per
Migration Path: From a Monolith to
Microservices, a mechanical export rather than an untangling exercise done under pressure.
Killing god classes
Bookshop’s Order class is this section’s running example of a god class: one entity that has accumulated
order lines, payment status, shipping status, and a denormalized snapshot of customer details, because it was
easiest to add each new concern to the class that was already there. A god class is the tactical-level version of
the same problem a missing module boundary is at the architectural level, and it gets split the same way: each
concern moves to the module that actually owns it. Concretely, Order loses its embedded shipping fields to a
new Shipment entity, owned by the Shipping module and referenced from Orders only by id — the same "splitting
the domain model" move this section uses everywhere `Order’s boundaries come up.
package com.example.orders.domain;
// Before: Order owns shipping state directly -- a cross-module concern trapped inside one class
public class Order {
private OrderId id;
private CustomerId customerId;
private List<OrderLine> lines;
private PaymentStatus paymentStatus;
private ShippingStatus shippingStatus; // belongs to Shipping, not Orders
private Address shippingAddress; // belongs to Shipping, not Orders
private String customerNameSnapshot; // belongs to Customers, not Orders
}
package com.example.orders.domain;
// After: Order owns only what Orders is responsible for
public class Order {
private OrderId id;
private CustomerId customerId;
private List<OrderLine> lines;
private PaymentStatus paymentStatus;
private ShipmentId shipmentId; // reference only -- Shipping owns the Shipment itself
}
ShipmentId is the only trace of shipping left inside Order; everything about how an order ships now lives
in the Shipping module’s own Shipment entity, reached through Shipping’s module API or learned about through
its own events, never through a field Order carries on its behalf.
Measuring coupling
Whether the boundaries above are actually holding is a measurable question, not a matter of opinion: Spring Modulith’s verification test itself is the first measurement — it either passes or names exactly which dependency violates a module boundary. Beyond pass/fail, module-to-module afferent and efferent coupling (how many other modules depend on a given module, and how many it depends on) is worth tracking over time the same way cyclomatic complexity is tracked for a single class: a module whose efferent coupling keeps climbing is accumulating dependencies on everything else, which is exactly the shape that makes it hardest to extract later.
Why this already buys most of the benefit, and the signal to go further
A modular monolith gets a team independently-testable modules, enforced boundaries, and a codebase where changes are localized to the module that owns them — the bulk of what Benefits and Drawbacks of Microservices lists as microservices' actual payoff — while still deploying, scaling, and operating as one process. None of the distributed-systems cost (network partitions between modules, eventual consistency the team did not choose, one-service-per-team operational overhead) applies, because none of that coupling crossed a process boundary.
The signal that a specific module has outgrown the modular monolith is not "it would be nice to split it" — it is one of these, concretely:
-
The module needs its own release cadence — it changes far more (or far less) often than the rest of the application, and every other module’s deployment is now waiting on, or blocked by, changes that have nothing to do with it.
-
The module needs its own scaling profile — Catalogue’s read-heavy, memory-hungry load pattern is the running example: scaling the whole
bookshopprocess to give Catalogue enough memory wastes resources on Orders, Payments, and Shipping, which do not need it. -
The module needs its own team, organizationally, and that team needs to own its deployment pipeline independently rather than coordinating every release through a shared one.
-
The module needs a different technology stack than the rest of the application can accommodate in one process.
Only once one of those four is true does Migration Path: From a Monolith to Microservices become the next page to read — and because the module was already isolated behind a Spring Modulith boundary with its own module API and its own events, extracting it into its own service is the mechanical continuation of work this page already did, not a fresh untangling project.
Figures
A ~25-line Spring Modulith example: the orders module publishing OrderPlaced in-process, consumed by the
shipping module. OrderPlaced lives in com.example.orders — the module’s base package, which Spring Modulith
treats as its published API — so shipping depends only on the event type orders deliberately exposes, never
on anything under orders.domain or orders.application:
// orders module -- the event type is part of the module's published API (base package, not an internal one)
package com.example.orders;
public record OrderPlaced(OrderId orderId, List<OrderLine> lines) {}
// orders module -- saves the aggregate and publishes the event in the same local transaction
package com.example.orders.application;
import com.example.orders.OrderPlaced;
import com.example.orders.domain.Order;
import com.example.orders.domain.OrderRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class PlaceOrderService {
private final OrderRepository orders;
private final ApplicationEventPublisher events;
PlaceOrderService(OrderRepository orders, ApplicationEventPublisher events) {
this.orders = orders;
this.events = events;
}
@Transactional
void placeOrder(PlaceOrderCommand command) {
Order order = Order.place(command.customerId(), command.lines());
orders.save(order);
// Same local transaction: Spring Modulith's event publication registry records the event
// durably before the commit, and delivers it to listeners only once the commit succeeds.
events.publishEvent(new OrderPlaced(order.id(), order.lines()));
}
}
// shipping module -- reacts in-process; its only dependency on orders is the published OrderPlaced type
package com.example.shipping;
import com.example.orders.OrderPlaced;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
@Component
class ShippingEventListener {
private final ShipmentService shipmentService;
ShippingEventListener(ShipmentService shipmentService) {
this.shipmentService = shipmentService;
}
@ApplicationModuleListener
void on(OrderPlaced event) {
shipmentService.scheduleShipment(event.orderId(), event.lines());
}
}
ApplicationModules.of(BookshopApplication.class).verify(), run as part of the test suite, confirms orders and
shipping communicate only through this event (and through `orders’ declared module API), never through a
direct reference into each other’s internal packages.
What each step of the migration buys, and what it still costs:
| Stage | What you gain | What it still costs |
|---|---|---|
Monolith |
Simple to deploy and operate; one build, one process, one database. |
Boundaries are only convention; any module can reach into any other’s internals; coupling grows unchecked. |
Modular monolith |
Enforced module boundaries (Spring Modulith/ArchUnit); localized changes; measurable coupling; still one deployable and one database, so no distributed-systems cost. |
Still cannot scale, release, or deploy one module independently of the others; a bug in one module’s process can still take the whole application down. |
Microservices |
Independent deployability, scaling, and team ownership per service; a failure in one service need not take down another. |
Network calls where method calls used to be, eventual consistency, and the full operational cost of running many deployables — worth paying only where a module actually needs one of the four signals above. |