How does Spring Modulith decide which types of a module are public API versus internal, and how is that enforced?
answer
- Base package = API; nested = internal
- verify() = ArchUnit static check at test time
- No runtime block — keep the test in CI
- @NamedInterface to expose a sub-package slice
- public modifier ≠ cross-module allowed
basics
~10 sTypes directly in the module's base package are public API; types in nested sub-packages are internal. Other modules referencing internal types cause ApplicationModules.of(App.class).verify() to fail.
solid answer
~40 sBy default a module's **base package** holds its public API — those top-level types may be referenced by other modules. Any type placed in a **nested sub-package** (conventionally `internal`, but any nesting counts) is module-private and must not be touched from outside. Enforcement is static: in a test you call `ApplicationModules.of(Application.class).verify()`, which uses ArchUnit under the hood to scan bytecode and fail the build if a module reads another module's internal types, or violates declared `allowedDependencies`. Nothing at runtime blocks the call — encapsulation is a *test-time* guarantee, so you keep the verify test in CI. To deliberately expose a type that lives in a sub-package, you use **named interfaces** (`@NamedInterface`) so other modules can depend on that named slice rather than the whole package.
code
java · 14 lines// The single test that enforces every boundary in CI
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(Application.class);
@Test
void verifiesModuleBoundaries() {
modules.verify(); // fails on internal-access, cycles, illegal deps
}
}
// Exposing a sub-package slice explicitly:
// com/example/app/order/spi/package-info.java
@org.springframework.modulith.NamedInterface("spi")
package com.example.app.order.spi;go deeper
Know that top-level types are public and internal sub-packages are hidden.
Explain verify() as the enforcement point and that public modifier doesn't grant cross-module access.
Add ArchUnit/static-bytecode basis, cycle detection, and named interfaces for selective exposure.
Discuss CI implications (deleting the test removes enforcement), runtime-vs-verification separation, and modeling SPI slices with named interfaces.
**The encapsulation rule.** Each application module has a **base package** (its direct sub-package of the main app package). Modulith splits a module's types into two categories: - **Exposed / public API:** types living **directly** in the base package (e.g. `com.example.app.order.OrderService`). These are the only types other modules are allowed to reference. - **Internal:** types living in **any nested sub-package** of the base package (e.g. `com.example.app.order.internal.OrderRepository`, `...order.internal.jpa.OrderEntity`). These are hidden from all other modules. The package name `internal` is a convention, but it's the *nesting* that makes a type internal, not the literal name. **How enforcement works.** Modulith does **not** use Java access modifiers or the module system (JPMS) at runtime. Instead it performs **static analysis** at test/build time, built on **ArchUnit** (which reads compiled bytecode). You write one test: ```java ApplicationModules.of(Application.class).verify(); ``` `verify()` checks, for every module: 1. No module references another module's **internal** types. 2. If a module declares `allowedDependencies`, it depends **only** on the modules named there (and the shared/open ones). 3. No **cycles** between modules. If any rule is broken, the test fails with a message naming the offending types. Because it's a test, you must keep it in your suite/CI — there is no runtime interceptor stopping a rogue call. **Deliberately exposing sub-package types — named interfaces.** Sometimes you want structure *inside* a module but still expose a specific slice. Annotate a package with `@NamedInterface("spi")` (in `package-info.java`) or types with `@NamedInterface`, giving that group a name. Other modules can then depend on `order :: spi` specifically. Without a named interface, a sub-package is fully internal. **Gotchas.** - Making a type `public` in Java does **not** make it cross-module-legal if it sits in a nested package — visibility ≠ Modulith exposure. - Spring beans in internal packages are still wired normally at runtime; the boundary only exists in the verification test. A missing/deleted verify test silently removes all enforcement. - Reflection or Spring proxying doesn't bypass the check because analysis is on static bytecode references. - Test classes are excluded from the production module model. **When to use.** This is the mechanism that turns a 'modular monolith' aspiration into a checked invariant: put implementation details under `internal`, expose only service/API types, and let `verify()` in CI stop accidental coupling before it spreads.
- A teammate made OrderRepository public in order.internal so the catalog module could use it. Will verify() still fail?Yes. Java `public` is irrelevant to Modulith. The type is in a nested (internal) package, so a cross-module reference is a violation regardless of its access modifier — unless it's exposed via a named interface.
- Where does the actual boundary checking come from technically?Modulith builds its module model and delegates the assertions to ArchUnit, which analyzes compiled bytecode for the offending package references, cycles, and dependency-rule breaches.
saying these in an interview costs you the question
- Assuming Java's public/private controls cross-module access
- Thinking there's a runtime guard preventing internal calls
- Believing 'internal' must literally be named internal to count
- Forgetting that removing the verify test silently disables all enforcement