You're introducing Spring Modulith into an existing Spring Boot monolith. How do you model modules, verify boundaries in CI, and roll it out without breaking the build on day one?
answer
- Capabilities -> direct sub-packages; internals nested
- One verify() test in CI = the guardrail
- Cycles are the day-one blocker -> events/shared module
- Permissive first, then allowedDependencies + @NamedInterface
- Documenter diagrams; @ApplicationModuleTest for isolation
basics
~10 sReorganize code so each business area is a direct sub-package with internals under nested packages, add one test calling ApplicationModules.of(App.class).verify() in CI, and adopt incrementally — start permissive, then add allowedDependencies module by module.
solid answer
~40 sFirst, map business capabilities to **direct sub-packages** of the main app package (one module each), pushing implementation types into nested `internal` packages so only intended API stays in the base package. Add a single verification test — `ApplicationModules.of(Application.class).verify()` — and wire it into CI; it uses ArchUnit to fail on internal-access, cycles, and illegal dependencies. Roll out incrementally: because default exposure is permissive on inter-module coupling, the model often passes early once internals are hidden and cycles removed. Then progressively lock down sensitive modules with `@ApplicationModule(allowedDependencies = …)` and expose curated slices via `@NamedInterface`. Use `Documenter` to generate module diagrams/canvases for review. Existing cyclic dependencies are the usual day-one blocker — break them with events or extracted shared modules. Keep the verify test green as a permanent architectural guardrail.
code
java · 22 lines// CI guardrail + generated docs in one test class
class ModularityTests {
static final ApplicationModules modules =
ApplicationModules.of(Application.class);
@Test // fails PR on internal access, cycles, illegal deps
void verifiesArchitecture() {
modules.verify();
}
@Test // emits C4/PlantUML + module canvases
void writesDocs() throws Exception {
new Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml();
}
}
// Later, lock a module down:
// com/example/app/billing/package-info.java
@ApplicationModule(allowedDependencies = { "order :: events" })
package com.example.app.billing;go deeper
Recognize the pieces: sub-packages per area, a verify() test in CI.
Describe hiding internals in nested packages and running verify() on every PR.
Add cycle-breaking via events/shared modules and incremental allowedDependencies/named-interface lock-down.
Frame the full rollout: capability mapping, permissive-then-restrict strategy, Documenter for reviewable architecture, @ApplicationModuleTest, and the test-time-not-runtime trade-off.
**Goal.** Convert an unstructured Spring Boot monolith into a **modular monolith** whose boundaries are *checked* by the build, adopted without a big-bang rewrite. **1. Model modules from capabilities.** Identify business capabilities (order, inventory, billing, notification). Make each a **direct sub-package** of the package holding `@SpringBootApplication` — that's what makes it an application module. Move each module's implementation detail (repositories, JPA entities, mappers, config) into **nested** packages (conventionally `…order.internal`, or further `…order.internal.jpa`). Leave only the intended API (service interface, DTOs, published events) in the **base package**. This single restructuring is what actually creates encapsulation, since base-package types are exposed and nested types are internal. **2. Add the verification test.** One JUnit test: ```java ApplicationModules modules = ApplicationModules.of(Application.class); @Test void verify() { modules.verify(); } ``` `verify()` (ArchUnit-based, test/build-time) enforces three things: no cross-module access to internals, no module cycles, and adherence to any declared `allowedDependencies`. Put it in the normal test source set so **CI** runs it on every PR — that's the guardrail; there is no runtime enforcement, so deleting this test silently removes all protection. **3. Expect cycles as the day-one blocker.** Real monoliths usually have **cyclic** package dependencies. `verify()` flags these immediately. Break them by: (a) introducing **domain events** (`ApplicationEventPublisher` / Modulith's event support) so a module publishes instead of calling back; (b) extracting a **shared** module for truly common types; or (c) inverting a dependency via a **named interface / SPI**. Only after cycles are gone will the base model pass. **4. Incremental lock-down.** Default policy is permissive on *which* modules you may depend on, so once internals are hidden and cycles removed, the model typically passes without any `allowedDependencies`. Then tighten deliberately: add `@ApplicationModule(allowedDependencies = { … })` to the most sensitive/leaf modules first, and expose narrow contracts with `@NamedInterface` (`"order :: spi"`). This staged approach avoids a red build on day one while steadily encoding the target architecture. **5. Documentation & review.** Use `Documenter` (`new Documenter(modules).writeDocumentation()`) to emit **C4/PlantUML** component diagrams and per-module **canvases** into `target/`/`build/`. Reviewers see the real dependency graph; drift shows up as diagram changes plus verify failures. **6. Testing modules in isolation.** Modulith adds `@ApplicationModuleTest` to bootstrap only a single module's slice (optionally its dependencies), enabling faster, focused integration tests once boundaries exist. **Gotchas / trade-offs.** - It's **compile/test-time** structuring, not runtime isolation — no separate class loaders or contexts; beans wire normally. Don't sell it as microservice-grade isolation. - Module-name strings in `allowedDependencies`/named interfaces aren't compile-checked; typos fail verification, not compilation. - Over-annotating early (locking every module) causes churn; prefer permissive-then-restrict. - Renaming a base package renames the module and breaks name-based references elsewhere. - Keep the verify test un-`@Disabled`; a skipped guardrail is worthless. **When to use.** Choose Modulith when a monolith is growing painful but you're not ready for (or don't want) microservices: you get enforced internal boundaries, event-based decoupling, isolated module tests, and living architecture docs — with a gradual, low-risk adoption path.
- verify() fails on a cyclic dependency between order and inventory. What are your options?Break the cycle by publishing a domain event from one side instead of a direct call, extracting the shared type into a separate shared module, or inverting the dependency through a named interface/SPI so only one direction remains.
- Why start permissive and add allowedDependencies later rather than annotating everything up front?Default exposure already hides internals and forbids cycles, so the model usually passes once code is reorganized. Annotating every module immediately creates churn and red builds; incremental lock-down encodes the target architecture without blocking day-one adoption.
- Is Modulith's boundary enforcement active at runtime?No. It's static analysis (ArchUnit) run by the verify() test at build time. At runtime it's an ordinary Spring Boot monolith with normal bean wiring, so the CI test is the only thing enforcing boundaries.
saying these in an interview costs you the question
- Selling Modulith as runtime/microservice-grade isolation
- Skipping or @Disabling the verify test yet claiming boundaries are enforced
- Annotating every module with allowedDependencies before removing cycles
- Believing an enabling annotation is required instead of package-based modeling