Architecture: Modular Monoliths & Boundaries¶
Most Spring Boot applications do not need to be microservices. They need to not become a ball of mud — where every class can call every other class and a change to orders breaks invoicing. A modular monolith gives you explicit boundaries inside one deployable, and Spring Modulith lets you verify them in a test.
Package by feature, with a public face¶
com.example.shop
├── ShopApplication.java
├── catalog/ ← module
│ ├── CatalogApi.java public: what other modules may call
│ ├── ProductView.java public DTO
│ └── internal/ everything else
│ ├── Product.java ProductRepository.java CatalogService.java
├── orders/
│ ├── OrderApi.java OrderPlaced.java (public event)
│ └── internal/ ...
└── invoicing/
└── internal/ InvoiceListener.java ...
Rules:
- A module is a direct sub-package of the application's root package.
- Other modules may use only the module's top-level package (its API types and events), never
internal. - No cycles between modules.
- Modules own their tables; no module queries another's tables.
Java's package-private visibility helps (make internal classes package-private where you can), but it cannot express "visible to my sub-packages, not to others." That is what the verification test is for.
Communicating between modules¶
Direct calls through the API are fine for queries:
For "something happened" — prefer events, so the publisher does not depend on every consumer:
// orders/internal/OrderService.java
@Transactional
public void place(PlaceOrder cmd) {
Order order = orders.save(Order.from(cmd));
events.publishEvent(new OrderPlaced(order.getId(), order.total()));
}
// invoicing/internal/InvoiceListener.java
@ApplicationModuleListener
void on(OrderPlaced event) {
invoices.createFor(event.orderId(), event.total());
}
@ApplicationModuleListener (Spring Modulith) is shorthand for an async,
transactional-after-commit listener running in its own transaction — the combination you
usually want between modules.
Spring Modulith¶
Add Spring Modulith (Initializr: "Spring Modulith"). Then one test verifies the structure:
class ModularityTest {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
@Test
void verifiesModularStructure() {
modules.verify(); // fails on cycles and on access to another module's internals
}
@Test
void writesDocumentation() {
new Documenter(modules).writeModulesAsPlantUml().writeIndividualModulesAsPlantUml();
}
}
If invoicing imports orders.internal.Order, verify() fails with a message naming the
offending type and dependency. Modulith also offers:
@ApplicationModuleTest— bootstraps only one module (and optionally its dependencies), like a slice test for your own architecture.- Event publication registry — persists events published to module listeners in a database table and marks them complete when listeners succeed, so events survive crashes and can be resubmitted. This removes most of the need for a hand-written outbox within a monolith.
- Event externalization — publish selected events to Kafka or other brokers
(
@Externalized), backed by the same registry.
The Modulith examples here were not run for this course; check the Spring Modulith documentation for the version matching your Boot release.
Worked example: extracting a module later¶
Because invoicing only depends on the OrderPlaced event, extracting it into its own
service is mechanical:
- Externalize
OrderPlacedto a Kafka topic. - Move the
invoicingpackage into a new Boot app with its own database (its tables were already private to it). - Replace
@ApplicationModuleListenerwith@KafkaListeneron the same method; make it idempotent (Level 3). - Delete the package from the monolith.
Compare that with extracting invoicing from a codebase where it joins orders tables and
calls OrderRepository directly — that is a rewrite, not an extraction.
How It Actually Works¶
ApplicationModules.of(...) uses ArchUnit to import the compiled classes of your
application and builds a model: each direct sub-package of the main class's package is a
module; its base package is the API (or you can declare named interfaces with
@NamedInterface); everything in sub-packages is internal. verify() then walks every type
dependency — field types, method signatures, constructor parameters, annotations — and
checks that each cross-module dependency targets an exposed type and that the module
dependency graph has no cycles.
The event publication registry hooks into Spring's event multicaster: when an event is published inside a transaction, Modulith writes one publication row per target listener in the same transaction. After commit, listeners run; each successful completion marks its row completed. Rows that never complete (the app crashed, the listener threw) remain incomplete and can be republished on restart or by a scheduled job — the outbox pattern, provided by the framework.
Common mistakes¶
- Package by layer (
controllers,services,repositories), which makes every module boundary invisible. - Shared "common" module that grows to contain half the domain.
- Modules reading each other's tables "just for a report."
- Synchronous calls for everything, recreating tight coupling inside one process.
- Splitting into microservices before the module boundaries are stable.
Exercise¶
- Restructure the capstone code into
catalog,orders, andinvoicingmodules withinternalsub-packages. - Add Spring Modulith and the
verify()test. Introduce a forbidden dependency on purpose and read the failure. - Replace a direct call from orders to invoicing with an
OrderPlacedevent and an@ApplicationModuleListener. - Enable the JPA event publication registry, make the listener throw, restart the app, and observe the incomplete publication being retried (per the Modulith docs for your version).