skip to content

A platform team is choosing how to enforce module boundaries across a large codebase: JVM visibility modifiers, compile-time; an architecture-test library like ArchUnit or Spring Modulith's ApplicationModules.verify(), test-time static analysis; or the Java Platform Module System, JPMS, runtime strong encapsulation. What does each actually guarantee, at what point in the pipeline does each one fail loudly, and what's the cost of over-relying on any single one?

level: principalimportance: should knowfreq 25%

answer

  1. three tiers: compiler visibility, architecture tests, JPMS runtime
  2. visibility = free but narrow scope, bypassed by reflection
  3. architecture tests = rich rules, test-time, need maintenance, deletable
  4. JPMS = strongest, JVM-enforced even against reflection, highest adoption cost
  5. defense-in-depth: layer them, don't rely on just one

basics

~30 s

Visibility modifiers stop bad code from even compiling, but only within one package or module. Architecture tests catch bigger-picture rule violations, like 'don't call the database from the web layer,' when you run your test suite. JPMS enforces boundaries at runtime for the whole application, even against tricks like reflection, but it's the most work to set up. Most teams use compiler visibility plus architecture tests, and skip JPMS unless they really need its strength.

solid answer

~1 min

The three sit at different points in the build/run pipeline with different strength. Visibility modifiers are enforced by the compiler at compile time, are essentially free, but are scoped narrowly, one package or one Gradle module, and can't express cross-cutting rules like layering or 'this API is only one interface, everything else is off-limits even though it's technically public for framework reasons.' Architecture tests run later, at test time, against compiled bytecode; they can express arbitrary structural rules across the whole codebase and fail the CI build — but they only catch what's compiled and tested, and the rules need maintenance as the codebase evolves. JPMS's exports/opens are the strongest: enforced by the JVM at class-load time, they block even reflection-based access unless explicitly opened — but they cost the most: every dependency needs to be modularized, split packages become build errors, and legitimate reflection-heavy frameworks need explicit opens grants that can reintroduce the very leakage you were trying to prevent. Over-relying on just one is a real risk: visibility-only teams find their internals get proxied into anyway by frameworks; test-only teams find their rules bit-rot or get deleted under deadline pressure since nothing physically stops a determined developer; JPMS-only teams burn enormous migration cost modularizing a legacy dependency graph for boundaries a lighter architecture test would have caught at a fraction of the cost. Most JVM shops layer compiler visibility for the cheap wins with architecture tests for the cross-cutting rules, and reserve full JPMS adoption for cases needing its runtime guarantee, since its cost is the highest of the three.

go deeper

for a junior

Should recognize there's more than one way to enforce boundaries, compiler rules vs tests, without needing the full JPMS comparison.

for a middle

Should know architecture tests exist to cover rules visibility modifiers can't express, and that reflection can bypass plain visibility.

for a senior

Should compare all three tiers accurately, including JPMS's exports/opens strength, and reason about a specific system's needs.

for a principal

Should make and justify an org-level enforcement-strategy decision across all three tiers for a given system's risk profile, team size, and deployment topology, including when JPMS's migration cost is or isn't worth paying.

## Three enforcement points, three stages The three enforcement points sit at different stages of the build/deploy pipeline and check fundamentally different things. - **Compiler-enforced visibility** — private/package-private/Kotlin internal — is checked by javac/kotlinc at compile time: a violation simply doesn't produce a `.class` file, and the scope is always small, one package in Java or one compilation module in Kotlin. - **Architecture tests**, ArchUnit and Spring Modulith's `ApplicationModules.verify()`, run later, as ordinary tests against already-compiled bytecode, walking the actual call/dependency graph and asserting structural rules that can span the whole codebase — layering, allowed module-to-module dependencies, naming conventions — none of which any single class's visibility modifier can express. - **JPMS's exports/opens** operate at the outermost point, runtime class-loading: the JVM itself checks module readability before it will link or reflectively access a class from another module, which is why it's the only one of the three that holds even against reflection unless a package is explicitly opened. ## Layered defense, because none of them is enough alone No single point is sufficient on its own, so treating them as a layered defense — analogous to defense-in-depth in security — is the practical pattern. | Mechanism | What it gives | What it costs | |---|---|---| | Compiler visibility | free and instantaneous | can't express cross-cutting rules or survive reflection | | Architecture tests | can express arbitrarily rich cross-cutting rules and catch violations early, at the commit | only see what's compiled and exercised by the build, and the rules themselves are code that needs maintenance and can be silently deleted, weakened, or bypassed under pressure since nothing physically stops a developer from doing so | | JPMS | provides the strongest, JVM-enforced guarantee, immune even to reflection | the highest adoption cost — every dependency in the graph needs to be a well-formed module, split packages become hard errors, and legitimate reflection-based frameworks need explicit opens grants that can end up re-widening the very hole you tried to close | ## What over-relying on one tier looks like 1. **A team that relies purely on visibility modifiers** finds their "hidden" internals still get reached by ORMs and DI containers via reflection, and can't express or enforce any rule that spans multiple mutually-visible packages, like layering, so architecturally significant boundaries silently erode with no automated signal. 2. **A team that relies purely on architecture tests** gets rich, whole-codebase rules, but those rules are just more code: they need to be written, kept accurate as the package structure evolves, and nothing stops someone from deleting or weakening a failing rule under deadline pressure the way a compile error can't simply be deleted to make it go away; the tests are also blind to anything the build doesn't compile or run. 3. **A team that goes all-in on JPMS** gets the strongest guarantee but pays the highest price: migrating a legacy dependency graph to well-formed modules is often a multi-quarter effort, automatic-module name fragility introduces its own new failure class, and every reflection-dependent library in the stack needs explicit opens accommodations revisited on every upgrade. ## Each tier's distinguishing failure mode - **Visibility-only enforcement:** the distinguishing failure mode is silent reflection-based leakage — nothing ever fails loudly, the "internal" class is just quietly reachable by any framework that wants it. - **Test-only enforcement:** the distinguishing failure mode is rule rot — a rule someone wrote two years ago either no longer matches the current package structure, so it passes green while checking nothing meaningful, or gets flagged as blocking and deleted in a hurry, with no JVM-level fallback once it's gone. - **Over-investing in JPMS:** the distinguishing failure mode is sunk-cost migration risk — teams spend months modularizing a dependency graph for boundaries a lighter architecture test would have caught at a fraction of the engineering cost, and then still need opens escape hatches for reflection-heavy frameworks that partially undo the guarantee anyway. ## The practical decision rule The practical decision rule most JVM shops land on: 1. Use compiler visibility for the free, always-on baseline. 2. Add architecture tests for the cross-cutting, deployable-unit-shaped rules where visibility alone can't reach. 3. Reserve full JPMS module-path adoption for cases that specifically need its JVM-enforced, reflection-proof guarantee — a library shipped to untrusted or adversarial consumers, or a genuine security boundary within the same process — because that's the only tier where its cost is clearly worth paying. ## Where it shows up — the two-tier pattern A concrete instance of the two-tier pattern, deliberately skipping full JPMS: Kotlin internal visibility hides each Spring Modulith module's implementation classes from other modules at compile time for free, while `ArchitectureTest` and `ModulithTest`, run as part of a fast local quality gate on every change, separately verify the allowed-dependency graph between modules and the internal layering within each module, catching violations neither internal alone nor human review would reliably catch, all without paying JPMS's module-path migration cost since the whole backend is one deployable application rather than a set of independently-versioned libraries handed to untrusted external consumers, which is exactly the scenario where JPMS's extra cost wouldn't currently be justified.

  • If a team has a solid ArchUnit/Spring Modulith test suite enforcing module boundaries, what does JPMS actually add on top that the tests can't provide?
    JPMS enforcement happens at JVM class-load time and holds even against reflection-based access that bypasses ordinary compiled references — an ArchUnit test only ever sees what the test suite compiles and exercises, so a reflective access path or a class loaded dynamically outside the tested code paths is invisible to it. JPMS closes that specific gap at the cost of requiring the whole dependency graph to be modularized.
  • Why is 'the architecture test can just be deleted under deadline pressure' considered a real risk rather than a hypothetical one?
    Unlike a compiler error, which structurally cannot be worked around without actually fixing the violation, a failing test is just code — someone with write access and enough urgency can comment it out, weaken its package-matching pattern, or delete it entirely, and the change compiles and ships fine. This is a known failure mode in real codebases under crunch, which is why some teams pair critical architecture tests with code-review requirements or branch protections specifically for the file containing them.
  • For a Spring Boot monolith that's never distributed as a library to external consumers, is full JPMS module-path adoption generally worth it?
    Usually not, in most real-world assessments — its strongest guarantee, protection against a hostile or careless external consumer reflecting into your internals, doesn't apply when the only 'consumer' is your own team's other modules within one deployable application, and the migration cost is high relative to that benefit. Kotlin internal plus architecture tests typically cover the practical need at a fraction of the cost, which is why this pattern, not full JPMS, is the common choice for that shape of system.

It's like securing a building with a locked office door, visibility modifiers, free but a master key or reflection opens it; a security guard checking badges against a written access list, architecture tests, catches policy violations but the list can be out of date or the guard skipped; and a keycard system wired into the building's access-control server, JPMS, enforced by the infrastructure itself, strongest, but expensive to install and rewire the whole building for.

saying these in an interview costs you the question

  • Treats visibility modifiers, architecture tests, and JPMS as interchangeable rather than different strengths at different pipeline stages
  • Claims architecture tests are just as tamper-proof as a compile error
  • Recommends full JPMS adoption for an internal monolith with no external library consumers, without weighing the migration cost
  • Doesn't know JPMS is the only one of the three that's robust against reflection
  • Can't articulate a concrete scenario where JPMS's extra cost is actually justified

context