DEV Community

Charles Hornick
Charles Hornick

Posted on

Ports & Adapters: Beyond the Theory - Isolating the Application with JPMS

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: the public keyword 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

  1. Runtime Adapter Hot-Swapping
  2. Organizing the Application with Package-by-Component
  3. Isolating with JPMS ← you are here
  4. Testing with Result (coming soon)
  5. Adapter Switching Strategies (coming soon)
  6. Spring Modulith + P&A (coming soon)

"JPMS will 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);

        [...]
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Proof the SnapshotBuilder is not accessible

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Test adapter module-info.java

module be.charleshornick.supra.facade.test {
    requires be.charleshornick.supra;
    requires core;

    exports be.charleshornick.supra.facade.test;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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-private controls 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)