PrepZone Logo
PrepZone

Modular Monolith Before Microservices

Spring Modulith for clean module boundaries inside a single deployable.

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
Each layer has one job. Dependencies point inward — controllers never touch the database directly.

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

Java
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

Java
@ApplicationModule(
        displayName = "Catalog",
        allowedDependencies = {"orders :: api", "shared"}
)
package com.bookstore.catalog;

import org.springframework.modulith.ApplicationModule;
Java
package com.bookstore.catalog.api;

public interface BookCatalogApi {

    Optional<BookSummary> findByIsbn(String isbn);

    List<BookSummary> searchByTitle(String query);
}
Java
@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

Java
// orders module listens to catalog events
@Component
class OrderCatalogEventListener {

    private final CartService cartService;

    @ApplicationModuleListener
    void onPriceChanged(BookPriceChanged event) {
        cartService.recalculateItemsWithIsbn(event.isbn());
    }
}
Java
// 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

Java
@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) with api and internal sub-packages.
  • Other modules depend only on api packages — never on internal implementation classes.
  • @ApplicationModuleListener replaces 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.