skip to content

As an architect, how would you operationalize Modulith verification in CI, and what are its structural limits you must account for?

level: principalimportance: should knowfreq 24%

answer

  1. verify() = fast context-free test, gate every merge
  2. regenerate Documenter docs in the same test
  3. tighten allowedDependencies incrementally; burn down violations
  4. blind to reflection, bean-name lookups, shared DB tables
  5. pair with events + @ApplicationModuleTest for runtime coverage

basics

~20 s

Put 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 s

I'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 lines
java
class 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

for a junior

Know verify() runs as a fast test that fails the build on boundary breaks.

for a middle

Explain wiring it plus Documenter into CI and tightening allowedDependencies over time.

for a senior

Discuss incremental adoption, burn-down of violations, and pairing with @ApplicationModuleTest for runtime coverage.

for a principal

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

context