Grenzen erzwingen
Eine Modulgrenze zählt nur, wenn sie erzwungen wird. Öffentliche Modul-APIs vs. interne Pakete - und den Build brechen, wenn ein Modul in die Interna eines anderen greift (ArchUnit, Modulith).
Deutsche Übersetzung in Arbeit
Diese Lektion ist noch nicht ins Deutsche übersetzt und wird daher auf Englisch angezeigt. Der Rest der Seite ist vollständig lokalisiert.
Auf dieser Seite
Feature modules give you boundaries on paper. But a boundary nobody enforces is a suggestion, and suggestions erode - one "quick" cross-module call at a time, until you're back to a ball of mud with folders. The discipline that makes a modular monolith actually work is enforcement: distinguishing a module's public API from its internals, and failing the build when one module reaches past another's front door.
Public API vs. internals
A module should expose a small, deliberate public surface and hide everything else. The rest of the system depends only on that surface, never on internal classes:
com.ledger.billing
├── BillingApi.java ← PUBLIC: the module's front door (interface + DTOs)
├── BillingFacade.java ← PUBLIC: implementation of the API
└── internal/ ← everything else - NOT for outside use
├── InvoiceRepository.java
├── FeeCalculator.java
└── InvoiceEntity.javaOther modules call BillingApi; they must not import anything under billing.internal. This is the same
idea as an aggregate root or a facade, applied at the module scale: one guarded entry point, a hidden interior
free to change. If billing can rework its InvoiceRepository without breaking anyone, the boundary is doing
its job.
Java's weak encapsulation - and the enforcement gap
The problem: Java's public/package-private modifiers are too coarse to express this. Make BillingFacade
usable from the orders module and it's public - which also lets orders import billing.internal.FeeCalculator
if that's public too. Package-private hides a class from other packages, but a module spans several packages, so
you can't make something visible within the module yet hidden outside it. The language won't stop the erosion, so
you enforce it with tooling.
ArchUnit: boundaries as tests
ArchUnit lets you write architectural rules as ordinary JUnit tests that fail the build when violated:
@Test
void modules_only_talk_through_public_apis() {
JavaClasses classes = new ClassFileImporter().importPackages("com.ledger");
noClasses().that().resideOutsideOfPackage("..billing..")
.should().dependOnClassesThat().resideInAPackage("..billing.internal..")
.check(classes); // FAILS the build if any outside class imports billing.internal
}Now a developer who sneaks import com.ledger.billing.internal.FeeCalculator into the orders module gets a
red build, not a code-review maybe. The boundary is executable. You can encode the whole ruleset - "no cyclic
dependencies between modules," "the domain layer imports no framework," "only ..api.. packages are
cross-module-visible" - as tests that run on every commit.
An enforced boundary is a promise; an unenforced one is a wish
The difference between a modular monolith and a big ball of mud with nicely-named folders is entirely enforcement. Without a build-breaking check, the first deadline-pressured shortcut ('I'll just call FeeCalculator directly, I'll clean it up later') punches a hole that never gets repaired, and others follow. The architecture decays not through one bad decision but a thousand small unchecked ones. A failing test at the moment of the shortcut is the only thing that reliably holds the line.
Beyond ArchUnit: the JDK and framework options
ArchUnit is the common choice, but there are others. The Java Platform Module System (JPMS, module-info.java)
enforces boundaries at the language level - a module exports only chosen packages, and the compiler rejects
access to the rest - though it's heavier and less used for in-app modules. And Spring Modulith (a later
lesson) provides Spring-native verification with a much simpler convention: sub-packages are automatically
internal unless declared otherwise, verified by a one-line test. Whichever you pick, the principle is identical:
make the boundary checkable so it can't quietly rot.
A paper 'Staff Only' sign on a door is an unenforced boundary: it works until someone's in a hurry, and once one person walks through, the norm collapses. An enforced boundary is a keycard lock: the door physically won't open without authorization, so the rule holds regardless of anyone's intentions or deadlines. ArchUnit is the keycard system for your module boundaries - the public API is the lobby anyone may enter, the internals are the badge-only back rooms, and the build fails the instant someone tries a door they're not cleared for. You don't rely on everyone reading and respecting the sign; you make the wrong move impossible.
In the modular ledger, the reporting module needs a transaction's computed fee. The fees module exposes a
FeesApi.feeFor(transactionId) method, but a developer under deadline instead writes import com.ledger.fees.internal.FeeCalculator and calls it directly because it's 'one line shorter.' Explain the
long-term damage, and describe the mechanism that would have prevented it at the moment of the shortcut.
Why do modular-monolith boundaries need tooling like ArchUnit to enforce them?
Key takeaways
- A module should expose a small public API and hide everything else in internal packages - the aggregate-root/facade idea at module scale.
- Java's public/package-private modifiers are too coarse: a module spans packages, so you can't be visible-within-module-but-hidden-outside without tooling.
- ArchUnit encodes architectural rules as JUnit tests that fail the build when a class depends on another module's internals or creates a cycle.
- Enforcement is the entire difference between a modular monolith and a ball of mud with nicely-named folders - unenforced boundaries erode one shortcut at a time.
- Alternatives include JPMS (language-level, heavier) and Spring Modulith (Spring-native, sub-packages internal by convention, verified in one test).