As an architect, how would you operationalize Modulith verification in CI, and what are its structural limits you must account for?
answer
- verify() = fast context-free test, gate every merge
- regenerate Documenter docs in the same test
- tighten allowedDependencies incrementally; burn down violations
- blind to reflection, bean-name lookups, shared DB tables
- pair with events + @ApplicationModuleTest for runtime coverage
basics
~20 sPut verify() in a fast unit test that runs on every build so boundary breaks fail the pipeline, and regenerate Documenter diagrams alongside it. Remember it's compile-time static analysis: reflection, config strings, and runtime wiring are invisible to it.
solid answer
~40 sI'd make ApplicationModules.of(App.class).verify() a first-class, always-on unit test — it's context-free and fast, so it belongs in the standard test task, gating merges. In the same test I'd run Documenter to regenerate PlantUML/C4 diagrams and module canvases so architecture docs never drift. I'd introduce allowedDependencies and named interfaces incrementally, expecting each tightening to surface a batch of existing violations to burn down. The key limits: it's ArchUnit-backed static analysis of compiled references, so reflection, bean-name lookups, event listeners resolved at runtime, and cross-boundary calls through generic interfaces can slip through. It also only sees code, not data coupling (shared tables) or temporal coupling. I'd pair it with event-based decoupling and integration tests (e.g. ApplicationModuleTest / scenario tests) to cover the runtime behavior verify() can't.
code
java · 19 linesclass ArchitectureTests {
static final ApplicationModules modules =
ApplicationModules.of(ShopApplication.class);
@Test
void enforceBoundaries() {
modules.verify(); // structural gate — fails the build on violations
}
@Test
void keepDocsInSync() {
new Documenter(modules).writeDocumentation(); // diagrams + canvases
}
}
// Runtime behavior verify() cannot check — a single-module slice test:
// @org.springframework.modulith.test.ApplicationModuleTest
// class OrderModuleTests { /* wires only 'order' + its allowed deps */ }go deeper
Know verify() runs as a fast test that fails the build on boundary breaks.
Explain wiring it plus Documenter into CI and tightening allowedDependencies over time.
Discuss incremental adoption, burn-down of violations, and pairing with @ApplicationModuleTest for runtime coverage.
Articulate the static-analysis limits (reflection, data/temporal coupling), why green verify() is necessary-not-sufficient, and the complementary event/data-ownership controls.
## Goal Make module boundaries a **build-breaking invariant**, not a wiki page. Spring Modulith's `verify()` is the enforcement leg; the architect's job is to operationalize it and understand where it stops. ## Operationalizing in CI 1. **One always-on verification test.** `ApplicationModules.of(App.class).verify()` needs no Spring context — it's ArchUnit static analysis — so it runs in seconds inside the normal `test`/`check` task. Keep exactly one such test near the application class; it gates every merge. 2. **Regenerate docs in the same test.** Call `new Documenter(modules).writeDocumentation()` (or the C4-style variants) so PlantUML diagrams and the **Application Module Canvas** are re-emitted every build. Living docs can't drift because they're derived from the same model that's being enforced. Render `.puml` → images in the pipeline if you publish them. 3. **Tighten incrementally.** Start open; add `@ApplicationModule(allowedDependencies = …)` and `@NamedInterface` module by module. Each tightening typically reveals a **batch of pre-existing violations** — treat that as a burn-down backlog, not a blocker to adoption. Optionally use ArchUnit's freeze/baseline patterns for legacy code so new violations fail while known ones are tracked. 4. **Fail fast, locate fast.** The `Violations` message names offending class → illegal target, so CI logs point straight at the fix. ## Structural limits (what verify() does NOT catch) - **Reflection & string-based wiring.** It analyzes *compiled type references*. `applicationContext.getBean("orderService")`, SpEL, or reflection cross a boundary invisibly. - **Runtime-resolved indirection.** Calls dispatched through a shared/generic interface, `ApplicationEventPublisher` payloads, or dependency inversion where the concrete type lives elsewhere may not register as a module edge. - **Non-code coupling.** Two modules sharing a **database table**, a Kafka topic schema, or a cache key are coupled in production but structurally invisible — verify() sees only Java references. - **Temporal / behavioral coupling.** Ordering assumptions, transactional boundaries, and 'module A must run before B' are behavior, not structure. - **Only sees the base-package model.** Misconfigured detection (custom packages, multi-module builds, code outside the base package) can leave whole areas unverified. ## Complementary controls - **Event-driven decoupling.** Replace direct cross-module calls with domain events (`@ApplicationModuleListener`) so the structural graph stays clean *and* the coupling is genuinely loose. (In this codebase the events pillar is intentional scaffolding — keep it.) - **Integration/behavioral tests.** `@ApplicationModuleTest` bootstraps a single module in isolation to verify it wires and behaves correctly; Modulith's `Scenario` API tests event flows. These cover the runtime dimension `verify()` can't. - **Data-boundary discipline.** Enforce per-module schemas/ownership by review and, where possible, separate schemas — static analysis won't do it for you. ## Trade-offs to communicate - verify() gives **high-confidence structural guarantees cheaply** — worth adopting early. - But 'green verify()' is **necessary, not sufficient**: it proves the code doesn't *reference* across illegal boundaries, not that the system is truly decoupled. Pair it with event decoupling, data ownership, and behavioral tests. - Diagram generation is essentially free insurance against doc rot — wire it in from day one. ## When to invest more If modules are heading toward independent deployability (future service extraction), lean harder on named interfaces, event-only communication, and separate data ownership so the structural boundary matches a real seam.
- Two modules pass verify() but share the same database table. Is that a boundary violation?Not one verify() can see — it only analyzes Java type references, not data coupling. Shared tables are real production coupling you must enforce by design/review or separate schemas.
- Why keep verify() as a plain unit test rather than an integration test?It's pure ArchUnit static analysis with no context bootstrap, so it runs in seconds. Making it a fast unit test means it gates every build cheaply without the cost of starting Spring.
- How do you cover the runtime coupling verify() misses?Use @ApplicationModuleTest to bootstrap a module in isolation and Modulith's Scenario API to assert event flows, plus event-driven decoupling so cross-module interaction is genuinely loose.
saying these in an interview costs you the question
- Treating green verify() as proof the system is fully decoupled
- Assuming it detects shared-database or event-schema coupling
- Believing reflection/bean-name cross-module calls are caught
- Running it as a slow context-bootstrapping test instead of a fast unit test