Why this matters
- Splitting into microservices too early creates distributed debugging, data consistency headaches, and deployment choreography for problems a well-structured monolith solves.
- Modulith packages catalog, orders, and payments as separate Java modules with explicit APIs — the compiler and ArchUnit tests catch cross-module violations before they reach production.
- When a module genuinely needs independent scaling, you extract it into a microservice with clear boundaries already defined.
ControllerHTTP in/out — @RestController
ServiceBusiness rules — @Service
RepositoryDatabase — @Repository
DatabasePostgreSQL / H2
Modulith building blocks
- Application modules — top-level packages (
catalog,orders,payments) each exposing an API package. @ApplicationModule— marks a module and its allowed dependencies.- Module events — typed events published within the monolith, with optional externalisation to Kafka later.
ApplicationModuleTest— verifies module structure: no illegal dependencies, no cycles.@NamedInterface— explicitly exposes types that other modules may depend on.
Package structure
com.bookstore
├── BookStoreApplication.java
├── catalog/
│ ├── CatalogModule.java // @ApplicationModule
│ ├── api/
│ │ ├── BookCatalogApi.java // public facade
│ │ └── BookSummary.java // shared DTO
│ ├── internal/
│ │ ├── BookController.java
│ │ ├── BookService.java
│ │ └── BookRepository.java
│ └── events/
│ └── BookPriceChanged.java
├── orders/
│ ├── OrdersModule.java
│ ├── api/
│ │ └── OrderPlacementApi.java
│ └── internal/
│ ├── OrderController.java
│ └── OrderService.java
└── payments/
├── PaymentsModule.java
└── internal/
└── PaymentService.java
Module declaration and API facade
@ApplicationModule(
displayName = "Catalog",
allowedDependencies = {"orders :: api", "shared"}
)
package com.bookstore.catalog;
import org.springframework.modulith.ApplicationModule;
package com.bookstore.catalog.api;
public interface BookCatalogApi {
Optional<BookSummary> findByIsbn(String isbn);
List<BookSummary> searchByTitle(String query);
}
@Service
class BookCatalogApiImpl implements BookCatalogApi {
private final BookService bookService;
@Override
public Optional<BookSummary> findByIsbn(String isbn) {
return bookService.findByIsbn(isbn).map(BookSummary::from);
}
}
Cross-module communication via events
// orders module listens to catalog events
@Component
class OrderCatalogEventListener {
private final CartService cartService;
@ApplicationModuleListener
void onPriceChanged(BookPriceChanged event) {
cartService.recalculateItemsWithIsbn(event.isbn());
}
}
// catalog module publishes
@Service
class BookPricingService {
private final ApplicationEventPublisher events;
public void updatePrice(String isbn, BigDecimal newPrice) {
bookRepository.updatePrice(isbn, newPrice);
events.publishEvent(new BookPriceChanged(isbn, newPrice));
}
}
Verify module structure in tests
@ApplicationModuleTest
class ModulithStructureTest {
@Test
void verifyModuleDependencies(ApplicationModules modules) {
modules.verify();
}
@Test
void generateModuleDocumentation(ApplicationModules modules) {
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
}
Quick recall
Everything you need if you only revisit this box.
- Organise BookStore into modules (
catalog,orders,payments) withapiandinternalsub-packages. - Other modules depend only on
apipackages — never oninternalimplementation classes. @ApplicationModuleListenerreplaces direct cross-module service calls for event-driven decoupling.ApplicationModuleTest+modules.verify()catch illegal dependencies at build time.- Modulith prepares clean extraction boundaries when a module later becomes a microservice.
Test yourself
Answer these before moving on — recall is what makes it stick.