Start Learning
Javaneer
Back to stage
Stage 4Β·The Modular Monolith

Enforcing Boundaries

A module boundary only counts if it's enforced. Public module APIs vs. internal packages, and failing the build when one module reaches into another's internals (ArchUnit, Modulith).

15 min readAdvanced
On this page

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.java

Other 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 building's keycard access, not a 'Staff Only' sign

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.

Hold the line under deadline pressure

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).
Was this lesson helpful?
Edit this page on GitHub