We had too many microservices and not enough sanity. So I tried something different: Spring Modulith. Here's what happened when I stopped splitting everything into services and started building modules that actually made sense.
Spring Modulith is a project from the Spring team (Oliver Drotbohm has been the main voice behind it, if you follow the GitHub repo or his talks at SpringOne) built for people who want the structure of separate services without the operational cost of actually running separate services. Concretely, it's three things bundled together: A set of conventions for organizing a Spring Boot codebase into modules, based mostly on package structure you're probably already using without thinking of it as "modules." A verification layer, built on ArchUnit, that turns those conventions into enforceable rules. Break a boundary, fail a test. A handful of auto-configured runtime features, most notably an eventing system built on Spring's existing ApplicationEventPublisher , plus observability and documentation tooling that understands your module structure because it already knows what your modules are. It is not a new architectural idea. Modular monoliths have existed for decades, and plenty of good engineers have been quietly building them without any framework's help, just discipline and code review. What Spring Modulith does is take that discipline and turn it into something the build server enforces instead of something a senior engineer has to keep reminding people about in pull requests. That's the whole pitch, really. It looks at your codebase, figures out what your modules are, and then lets you write a test that fails if anyone violates the boundaries those modules are supposed to have. How It Figures Out What a "Module" Is By default, Spring Modulith treats every top-level package directly under your main application package (the one with @SpringBootApplication on it) as a module. So if your application class lives in com.example , and you've got com.example.orders , com.example.inventory , and com.example.notifications , those three packages are your three modules. No annotation required. No XML config. It's purely structural. You can ask Spring Modulith to describe your application to you at runtime or in a test, which is a good sanity check when you're first setting this up: import org.springframework.modulith.core.ApplicationModules; public class InspectModules { public static void main(String[] args) { ApplicationModules modules = ApplicationModules.of(Application.class); modules.forEach(module -> System.out.println(module.getDisplayName())); } } Run that against a small app and you'll get a printed list of every module it detected, along with what it considers each module's dependencies to be, based on actual import statements in your code. It's a little unnerving the first time you run it, because it usually finds a dependency you didn't know existed. Declaring Explicit APIs and Dependencies The default package-depth convention works fine for straightforward cases, but Spring Modulith also lets you be explicit, which matters once your modules get bigger. You can mark a sub-package as a deliberate public entry point using @NamedInterface , even if it's nested deeper than the module root: // com/example/orders/api/package-info.java @org.springframework.modulith.NamedInterface("orders-api") package com.example.orders.api; And you can go further and declare, at the module level, exactly which other modules it's allowed to depend on: // com/example/orders/package-info.java @org.springframework.modulith.ApplicationModule( allowedDependencies = { "inventory::inventory-api" } ) package com.example.orders; That second one is the part that actually changes how a team behaves. It's one thing to say "please don't reach into the inventory module's internals" in a Confluence doc nobody reads. It's another thing entirely when the build fails because someone tried to depend on a part of inventory that was never declared as a public interface. I've watched this stop a bad import in a pull request before a reviewer even had to say anything about it. The Package Structure Thing (This Tripped Me Up At First) Here's roughly what a module looks like once you buy into the convention: com.example.orders ├── OrderModule.java ├── internal/ │ ├── OrderRepository.java │ ├── OrderValidator.java │ └── OrderJpaEntity.java ├── Order.java ├── OrderCreatedEvent.java └── api/ ├── package-info.java └── OrderSummary.java Anything sitting inside internal is invisible to every other module, enforced not through access modifiers but through the verification test you run in CI. Classes sitting directly under orders , or inside an explicitly declared api package like the one above, count as public. When I first tried this on a side project (a small inventory tracking app I built to test the waters before pitching this at work), I kept getting verification failures because I'd tucked a repository class one level too shallow. It took maybe twenty minutes of confusion to realize the convention was package depth, not naming. Once it clicked, it clicked hard, and I stopped fighting it. Verifying Boundaries With ArchUnit Under the Hood This is the part that sold me. Spring Modulith ships integration with ArchUnit, and one test verifies your entire module structure is clean. import org.springframework.modulith.core.ApplicationModules; import org.springframework.modulith.docs.Documenter; import org.junit.jupiter.api.Test; class ModularityTests { ApplicationModules modules = ApplicationModules.of(Application.class); @Test void verifiesModularStructure() { modules.verify(); } @Test void writesDocumentation() { new Documenter(modules) .writeDocumentation() .writeIndividualModulesAsPlantUml(); } } Run that test and it fails loudly if module A reaches into module B's internal package. The failure message is genuinely useful, something along the lines of: Module 'orders' depends on non-exposed type com.example.inventory.internal.InventoryRepository within module 'inventory'! No more "oh, someone just imported the repository directly because it was convenient" moments that slowly rot your architecture over eighteen months. We added this test to our CI pipeline on the inventory project, and within two weeks it caught three violations that had crept in from people (okay, me, one of those was me) taking shortcuts under deadline pressure. That alone justified the setup time. Events Instead of Direct Calls Here's the pattern that took the most getting used to. Instead of module A calling a service method on module B directly, you publish an event and let module B listen for it. // In the orders module: com.example.orders.OrderService package com.example.orders; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @Service class OrderService { private final OrderRepository repository; private final ApplicationEventPublisher events; OrderService(OrderRepository repository, ApplicationEventPublisher events) { this.repository = repository; this.events = events; } Order placeOrder(String customerId, List<OrderLine> lines) { Order order = new Order(UUID.randomUUID(), customerId, lines, OrderStatus.PLACED); repository.save(order); events.publishEvent(new OrderPlaced(order.id(), order.customerId(), lines)); return order; } } // The event itself, sitting at the top level so it's part of the public API package com.example.orders; public record OrderPlaced(UUID orderId, String customerId, List<OrderLine> lines) {} // In the inventory module: com.example.inventory.InventoryEventListener package com.example.inventory; import org.springframework.modulith.events.ApplicationModuleListener; import org.springframework.stereotype.Component; @Component class InventoryEventListener { private final StockRepository stockRepository; InventoryEventListener(StockRepository stockRepository) { this.stockRepository = stockRepository; } @ApplicationModuleLi...