This is the third article in the "Ports & Adapters: Beyond the Theory" series.
Article 2 showed how to organize the inside of the application by combining P&A with Simon Brown's package-by-component.
This article tackles the hole left by the previous article: thepublickeyword exposing more than necessary.
Every line of code in the companion repository was written by hand. No AI-generated code.GitHub Repository: GitHub - branch
article/3-jpms-isolation
Series - Ports & Adapters: Beyond the Theory
- Runtime Adapter Hot-Swapping
- Organizing the Application with Package-by-Component
- Isolating with JPMS ← you are here
- Testing with Result (coming soon)
- Adapter Switching Strategies (coming soon)
- Spring Modulith + P&A (coming soon)
"
JPMSwill keep the local classes in line" - Grand Moff Tarkin, former Software Engineer.
Recap: where article 2 left us
Article 2 showed how to combine Ports & Adapters with Simon Brown's package-by-component to organize the inside of the application. Java's package-private became the default visibility level, hiding the internal logic of each use case.
But we ended on an honest assessment: primary port constructors are public, and so is SnapshotBuilder. package-private covers a good portion of the isolation, but the rest is exposed, and Java offers nothing between public and package-private.
This is where JPMS comes in.
JPMS refresher
The Java Platform Module System (JPMS), introduced in Java 9, adds a level of isolation above packages: the module. A module explicitly declares what it exports and what it needs through a module-info.java file.
The key point is that a module is closed by default. If a package is not explicitly exported, it is invisible from outside the module, even if the classes it contains are public.
That's exactly the mechanism we were missing.
Reorganizing the snapshot package
Before setting up modules, there's a problem to fix in the application's organization. The snapshot/ package contains both types that need to be visible to adapters (Snapshot, Action) and purely internal types (CreationPoint, InvestedPoint, CreationPointConsumer, Recorder, SnapshotBuilder).
But JPMS exports packages, not classes. There's no way to say "export Snapshot but hide CreationPoint" if they're in the same package.
The solution is to separate the two categories:
state/
├── CreationPoint.java # internal - not exported
├── CreationPointConsumer.java # internal - not exported
├── InvestedPoint.java # internal - not exported
├── Recorder.java # internal - not exported
├── SnapshotBuilder.java # internal - not exported
└── snapshot/
├── Snapshot.java # exposed - adapters need it
└── Action.java # exposed - part of the contract
state describes the internal state management mechanics, while state.snapshot contains what comes out of it. The state package is not exported, unlike the state.snapshot package, meaning internal classes disappear for the outside world.
The application module
Here is the application's module-info.java:
module be.charleshornick.supra {
requires core; // Pragmatica-core
exports be.charleshornick.supra; // ErrorCause, ForStoringSnapshot
exports be.charleshornick.supra.create; // Primary port
exports be.charleshornick.supra.define; // ForLoadingSnapshot, ToCharacter
exports be.charleshornick.supra.define.race; // Primary port
exports be.charleshornick.supra.define.profession; // Primary port
exports be.charleshornick.supra.define.characteristic; // Primary port
exports be.charleshornick.supra.retrieve.race; // Primary port
exports be.charleshornick.supra.retrieve.profession; // Primary port
exports be.charleshornick.supra.retrieve.snapshot; // Primary port
exports be.charleshornick.supra.race; // Vocabulary
exports be.charleshornick.supra.profession; // Vocabulary
exports be.charleshornick.supra.characteristic; // Vocabulary
exports be.charleshornick.supra.state.snapshot; // Exposed state
}
What stands out is that everything not listed is invisible: CreationPoint, InvestedPoint, CreationPointConsumer, Recorder, SnapshotBuilder are public in their package but no other module can access them. The compiler refuses the import.
The public from article 2 is no longer a master key. It means "public within the module".
Why exports are not qualified
You'll notice that no export uses the to clause:
// We do NOT do this:
exports be.charleshornick.supra.create to be.charleshornick.supra.bootstrap;
The reason is directly tied to Cockburn. The application is "blissfully ignorant of the nature of the input device". If the application declares which modules it exports to, it knows its consumers. That's a form of coupling, even if it's at the module level and not at the code level.
A JDBC adapter today, a MongoDB adapter tomorrow, an in-memory adapter for tests. The application doesn't know and shouldn't know who implements its secondary ports, so exports remain open.
Adapters enter the scene
Until now in the series, the application lived alone. With JPMS and modules, it's time to introduce what lives on the outside.
The primary adapter
A primary adapter translates signals from an external actor to the application's ports. In a complete system, this would be a REST controller, a web interface, a CLI. But for this article, and following the approach Cockburn uses in Hexagonal Architecture Explained, a test console adapter is sufficient:
public class CreateFullCharacter {
private final CreateCharacter createCharacter;
private final DefineRace defineRace;
private final DefineProfession defineProfession;
private final DefineCharacteristic defineCharacteristic;
private final GetLastestSnapshot getLastestSnapshot;
private final GetAllSnapshots getAllSnapshots;
final String characterName = "Borgrim";
public CreateFullCharacter(final CreateCharacter createCharacter,
final DefineRace defineRace,
final DefineProfession defineProfession,
final DefineCharacteristic defineCharacteristic,
final GetLastestSnapshot getLastestSnapshot,
final GetAllSnapshots getAllSnapshots) {
this.createCharacter = createCharacter;
this.defineRace = defineRace;
this.defineProfession = defineProfession;
this.defineCharacteristic = defineCharacteristic;
this.getLastestSnapshot = getLastestSnapshot;
this.getAllSnapshots = getAllSnapshots;
}
public void run() {
System.out.println("=== Character Creation Test ===");
this.createCharacter
.named(this.characterName)
.onSuccess(s -> System.out.println("✓ Created: " + s.name()))
.onFailure(CreateFullCharacter::logError);
this.defineRace
.named(HIGH_ELF)
.toCharacterNamed(this.characterName)
.onSuccess(s -> System.out.println("✓ Race: " + s.race().name()))
.onFailure(CreateFullCharacter::logError);
this.defineProfession
.named(ELF_ADVENTURER)
.toCharacterNamed(this.characterName)
.onSuccess(s -> System.out.println("✓ Profession: " + s.profession().name()))
.onFailure(CreateFullCharacter::logError);
[...]
}
}
This adapter only knows the primary ports. It cannot instantiate a SnapshotBuilder, nor access a CreationPoint, nor even touch a Recorder, JPMS forbids it at compile time.
An important point about primary adapters: they are shaped by the consumer they serve, not by the application. The idea is the same as BFFs (Backend For Frontend) in microservices: an element dedicated to a front-end, developed by the team that knows that front-end. The application behind doesn't change, only the translation does.
This means the front-end team can develop their adapters in parallel against the ports, using fakes, without being blocked by the application's development. As explained in article 2, the port is an API, a contract, not a synchronization point.
The faked secondary adapter
For this article, the secondary adapter is an in-memory fake located inside the composition root, exactly like the FixedTaxRateRepository Cockburn uses in his book: a trivial implementation that allows testing the wiring without infrastructure.
public class InMemorySnapshotStorage implements ForLoadingSnapshot, ForStoringSnapshot, ForGettingSnapshot {
private final Map<String, TreeSet<Snapshot>> store = new HashMap<>();
@Override
public Option<Snapshot> getLastSnapshot(final String characterName) {
return Option.option(this.store.get(characterName))
.filter(set -> !set.isEmpty())
.map(TreeSet::last);
}
@Override
public Result<Snapshot> store(final Snapshot snapshot) {
this.store.computeIfAbsent(snapshot.name(), _ -> new TreeSet<>()).add(snapshot);
return Result.ok(snapshot);
}
@Override
public Option<Snapshot> theLastest(final String name) {
return this.getLastSnapshot(name);
}
@Override
public List<Snapshot> allOrdered(final String name) {
return Option.option(this.store.get(name))
.map(List::copyOf)
.or(List.of());
}
}
This implements the secondary port ForStoringSnapshot. It sees the port interface and the Snapshot type because those packages are exported, but it doesn't see SnapshotBuilder, CreationPoint, or anything else. It doesn't need to, and JPMS makes sure it can't.
The Composition Root
The composition root is the only place that sees everything: the application, the primary adapters, the secondary adapters. It's the one that wires implementations together and launches the application.
public class Application {
void main() {
final var raceStorage = new InMemoryRaceStorage();
final var professionStorage = new InMemoryProfessionStorage();
final var snapshotStorage = new InMemorySnapshotStorage();
final var createCharacter = new CreateCharacter(_ -> true, snapshotStorage);
final var defineRace = new DefineRace(snapshotStorage, snapshotStorage, raceStorage);
final var defineProfession = new DefineProfession(snapshotStorage, snapshotStorage, professionStorage);
final var defineCharacteristic = new DefineCharacteristic(snapshotStorage, snapshotStorage);
final var getLastestSnapshot = new GetLastestSnapshot(snapshotStorage);
final var getAllSnapshots = new GetAllSnapshots(snapshotStorage);
final var testAdapter = new CreateFullCharacter(
createCharacter,
defineRace,
defineProfession,
defineCharacteristic,
getLastestSnapshot,
getAllSnapshots
);
testAdapter.run();
}
}
No Spring, no automatic injection, no annotations — the wiring is explicit, readable, and verified at compile time. The composition root is the only module with a dependency on all the others, and that's its job.
Composition root module-info.java
module be.charleshornick.supra.bootstrap {
requires be.charleshornick.supra;
requires be.charleshornick.supra.facade.test;
requires core;
}
Test adapter module-info.java
module be.charleshornick.supra.facade.test {
requires be.charleshornick.supra;
requires core;
exports be.charleshornick.supra.facade.test;
}
Adapters export their package so the composition root can instantiate them. The application doesn't even know these modules exist.
The trap: opens ... to
JPMS offers an opens directive that allows reflection on a package. And the temptation is real:
// DON'T DO THIS
module be.charleshornick.supra {
opens be.charleshornick.supra.race to com.fasterxml.jackson.databind;
}
This would allow Jackson to deserialize Race types through reflection. Convenient. Except the application just declared that it knows about Jackson. It's no longer "blissfully ignorant" of the technology. The boundary is broken, right in the module-info.java itself.
And yes, Jackson is a technology. The choice to use Jackson over Gson belongs to the secondary adapter, not the application.
The right approach is for the secondary adapter to handle deserialization. It receives JSON, builds a Race through the public constructor, and passes it to the port. The application doesn't know that JSON exists somewhere. The adapter does its job as an adapter.
@Transactional does not belong in the application
Since we're talking about adapters and the composition root, a point that comes up often in discussions: where to place transactions?
Some argue that @Transactional can go in the application (what they call the "domain") because jakarta.transaction.Transactional is a Java standard and therefore "not technology-specific."
This is wrong. jakarta.transaction.Transactional presupposes a transaction manager, which presupposes transactional persistence. Cockburn's application shouldn't even know there's a database. A transaction annotation in the application means the application knows there's transactional persistence behind it.
But beyond theory, there's a practical argument. A single primary port can be wired with different secondary adapters depending on the context. A JDBC adapter needs transactions. An in-memory adapter doesn't. If @Transactional is in the application, it applies in both cases, forcing a dependency on a transaction manager that might not even exist in the in-memory context.
The transactional decision depends on the combination of primary adapter and secondary adapter. Only the composition root knows that combination. @Transactional goes on the handler in the primary adapter, or in a composition root configuration. Not in the application.
The application remains "blissfully ignorant" of everything.
The honest trade-off: the public constructor survives
JPMS closes non-exported packages. The public on a CreationPoint or a SnapshotBuilder no longer leaks to adapters, and that's the main gain.
But the public constructor of primary ports survives. CreateCharacter is in an exported package because the primary adapter needs to see it. And as discussed above, JPMS exports packages, not classes, so the public constructor remains accessible to any module that depends on the application.
An adapter doing new CreateCharacter(myFakeChecker, myFakeStorage) still compiles.
One could separate the port and its constructor into two different packages, one exported and one not. But that would break the per-use-case cohesion from Brown, the approach we set up in article 2. The cure would be worse than the disease.
Architecture guides, it doesn't prevent. No technical mechanism will replace the discipline of a team that understands the why behind the structure. JPMS + Brown + package-private cover nearly all of the isolation. The public constructor remains an act of trust in the team.
JPMS trade-offs
module-info.java complexity
The larger the application grows, the longer the export list gets. It's verbose but explicit, and a module-info.java of 30 lines remains infinitely more readable than an ArchUnit setup with 50 rules.
Tooling not always module-friendly
Some libraries and tools are not yet ready for JPMS. This is less and less true with recent Java versions and major frameworks, but it's a point to check before adoption.
More constrained tests
Tests must respect the same boundaries as production code. A test cannot access a non-exported package. This is a constraint that forces testing through ports, which is the right level of testing for the application. Internal details shouldn't be tested directly.
Spring Boot and JPMS
Historically, Spring Boot and JPMS didn't get along well. With Spring Boot 4.x and Java 25, the situation has improved considerably. But it's a topic to watch when adding real Spring adapters in the following articles.
Conclusion
Article 2 posed the problem: package-private covers a good portion of the isolation but public is a master key. JPMS fills the gap by adding a level of control above.
Two levels of isolation, two mechanisms, complementary:
-
JPMS controls what leaves the module. Non-exported packages are invisible, even if classes are
public. -
package-privatecontrols what leaves the package. Internal classes stay invisible within the module itself.
Brown organizes the inside through package-private which enforces at the package level. JPMS enforces at the module level. Cockburn protects the boundary between the inside and the outside.
The application is now structured, isolated, and verifiable at compile time. But we haven't talked about tests yet. How do we guarantee that primary ports work correctly, that secondary adapters respect their contract, and that everything holds together without test duplication?
That's the subject of article 4: testing in P&A.
The example application evolves from article 2 with the addition of JPMS modules, a test console adapter, an in-memory adapter, and a composition root. The application itself hasn't changed, only the project structure has evolved.
Tech stack: Java 25, Pragmatica (Result<T>, Option<T>), JUnit 6, AssertJ, Maven. No Spring in this article.
Source code: GitHub - article/3-jpms-isolation
This article is part of the "Ports & Adapters: Beyond the Theory" series. The series was initiated after Alistair Cockburn, creator of the Ports & Adapters pattern and co-author of the Agile Manifesto, shared the first article and described the approach as "an amazing use of Hexagonal Architecture."
The architectural decisions in this series are grounded in Cockburn's original 2005 article and in Hexagonal Architecture Explained (Cockburn & Garrido de Paz, updated 1st edition, 2025).

Top comments (0)