Microservices add distributed systems complexity before you've earned it. Spring Modulith gives you module boundaries, event-driven communication, and clear architecture — without the network overhead.
JOptimize Team
The microservices debate has matured. The industry has learned that splitting a codebase into dozens of services before you understand your domain creates distributed systems problems — network latency, distributed transactions, complex deployments — without providing the benefits you hoped for. The modulith is the answer to this: strong module boundaries, clear architecture, and event-driven communication, all within a single deployable unit.
Spring Modulith is Spring's framework for building modular monoliths. It enforces module boundaries at development time, provides event-driven communication between modules, and generates architectural documentation automatically.
A big ball of mud monolith has no boundaries. Any class can call any other class. The order service reaches directly into the inventory database. The billing module reads user tables it shouldn't know about. Over time, every change breaks something unexpected, and the codebase becomes unmaintainable.
Premature microservices solve the coupling problem by making modules into separate processes — but introduce distributed systems complexity: network failures, eventual consistency, distributed tracing, service discovery, and deployment pipelines for 15 services instead of one. Most teams are not ready for this complexity on day one.
The modulith sits between these extremes. Each module is a self-contained unit with its own public API. Modules communicate through events, not direct method calls. The architectural rules are enforced by the framework. But everything runs in a single JVM, so you get transactions, simple deployment, and fast integration tests.
In Spring Modulith, a module is simply a top-level package under your application package. The framework discovers modules automatically:
com.example.shop/
├── ShopApplication.java
├── order/ ← Order module
│ ├── OrderController.java (public API)
│ ├── OrderService.java (public API)
│ ├── Order.java (public API)
│ └── internal/ ← Internal — not accessible from other modules
│ ├── OrderRepository.java
│ ├── OrderValidator.java
│ └── OrderMapper.java
├── inventory/ ← Inventory module
│ ├── InventoryService.java (public API)
│ └── internal/
│ ├── InventoryRepository.java
│ └── StockLevel.java
└── notification/ ← Notification module
├── NotificationService.java
└── internal/
└── EmailSender.java
Anything in an internal subpackage is module-private. Spring Modulith enforces this at test time — if the order module tries to access InventoryRepository directly, the architecture test fails.
@SpringBootApplication @EnableSpringDataWebSupport public class ShopApplication { public static void main(String[] args) { SpringApplication.run(ShopApplication.class, args); } }
No special annotations needed to define modules. The package structure IS the architecture.
Spring Modulith provides a test that verifies your architecture rules are not violated:
@SpringBootTest public class ModularityTests { ApplicationModules modules = ApplicationModules.of(ShopApplication.class); @Test void verifyModularStructure() { modules.verify(); // Fails if: // - Module A accesses Module B's internal classes // - There are circular dependencies between modules // - A module accesses another module's persistence layer directly } @Test void printModuleStructure() { modules.forEach(System.out::println); // Prints the module graph — useful for onboarding } }
This test runs in CI. Violations fail the build. You don't need code reviews to enforce architecture — the framework does it.
Modules should not call each other directly. Direct calls create tight coupling — the order module needs to know about the inventory module's API, the notification module, and anything else that needs to happen when an order is placed.
Spring Modulith uses Spring's ApplicationEventPublisher for loose coupling between modules:
// Order module: publishes an event, doesn't know who listens @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepo; private final ApplicationEventPublisher events; @Transactional public Order placeOrder(PlaceOrderRequest req) { Order order = orderRepo.save(Order.from(req)); // Publish event — order module doesn't know about inventory or notifications events.publishEvent(new OrderPlacedEvent(order.getId(), order.getItems())); return order; } } // Define the event in the order module (it's part of its public API) public record OrderPlacedEvent(Long orderId, List<OrderItem> items) {}
// Inventory module: listens without the order module knowing @Component public class InventoryEventHandler { @ApplicationModuleListener // Spring Modulith annotation — async by default public void onOrderPlaced(OrderPlacedEvent event) { event.items().forEach(item -> inventoryService.reserveStock(item.productId(), item.quantity()) ); } } // Notification module: also listens independently @Component public class NotificationEventHandler { @ApplicationModuleListener public void onOrderPlaced(OrderPlacedEvent event) { notificationService.sendOrderConfirmation(event.orderId()); } }
@ApplicationModuleListener is Spring Modulith's annotation that makes event processing transactional and asynchronous by default. If the inventory reservation fails, Spring Modulith can retry the event. The order transaction is not affected.
Here's where Spring Modulith becomes strategically powerful. When you're ready to extract a module into a separate service, you externalize its events to a message broker:
// Mark event for externalization to Kafka @Externalized("shop.orders") public record OrderPlacedEvent(Long orderId, List<OrderItem> items) {}
spring: modulith: events: kafka: enabled: true
With this configuration, Spring Modulith automatically publishes OrderPlacedEvent to the shop.orders Kafka topic when it's raised. In-process listeners still work — you've added Kafka without changing anything else.
When you later extract the notification module into its own service, it subscribes to the same Kafka topic. The order module code doesn't change at all. This is the migration path from modulith to microservices that doesn't require a big-bang rewrite.
Spring Modulith can generate C4 component diagrams and PlantUML from your module structure:
@Test void generateDocumentation() throws Exception { new Documenter(ApplicationModules.of(ShopApplication.class)) .writeModulesAsPlantUml() // generates modules.puml .writeIndividualModulesAsPlantUml() // one diagram per module .writeAggregatingDocument(); // HTML report }
The documentation is generated from code, so it's always accurate. No more architecture diagrams that drift from reality.
One of the biggest practical benefits of Spring Modulith is isolated module testing. You can boot only the modules you need:
@ApplicationModuleTest // Boots only the order module and its dependencies class OrderModuleTests { @Test void placingOrderPublishesEvent( @Autowired OrderService orderService, PublishedEvents events) { orderService.placeOrder(new PlaceOrderRequest(/* ... */)); // Verify event was published without starting inventory or notification modules events.ofType(OrderPlacedEvent.class) .matching(OrderPlacedEvent::orderId, 1L) .hasSize(1); } }
This makes integration tests significantly faster. You're not starting the entire application context for every module test.
Choose Spring Modulith when:
Choose microservices when:
The Spring Modulith path is clear: start with a well-structured modulith, and extract microservices when there's a concrete, measurable reason to do so.
@ApplicationModuleListener is async by default; using synchronous events for cross-module calls recreates the coupling you were trying to avoidmodules.verify() in CI — the architecture test is only useful if it fails the buildSpring Modulith offers a pragmatic middle path between the big ball of mud and premature microservices. Package-based module boundaries, event-driven inter-module communication, automated architecture verification, and a clear migration path to microservices when needed. For most greenfield Java applications in 2026, starting with a well-structured modulith is the right architectural choice.
JOptimize analyzes your Spring application — modulith or microservice — for N+1 queries, missing indexes, and inefficient data access patterns that often hide inside module boundaries.
Good architecture and good performance go hand in hand.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.