Charles Hornick

Ports & Adapters: Beyond the Theory — Isoler l'application avec JPMS

Le mot-clé public n'est plus un passe-partout : il signifie désormais « public au sein du module ».

🇬🇧 English version on dev.to
Troisième article de la série « Ports & Adapters: Beyond the Theory ». L'article 2 montrait comment organiser l'intérieur de l'application en combinant P&A avec le package-by-component de Simon Brown. Celui-ci s'attaque au trou laissé par l'article précédent : le mot-clé public qui expose plus que nécessaire. Chaque ligne de code du repo compagnon a été écrite à la main. Aucun code généré par IA.
📦
« JPMS will keep the local classes in line » — Grand Moff Tarkin, ancien Software Engineer.

Récap : où l'article 2 nous a laissés

L'article 2 montrait comment combiner Ports & Adapters avec le package-by-component de Simon Brown pour organiser l'intérieur de l'application. Le package-private de Java devenait le niveau de visibilité par défaut, cachant la logique interne de chaque cas d'usage.

Mais nous avions terminé sur un constat honnête : les constructeurs des ports primaires sont public, et SnapshotBuilder aussi. package-private couvre une bonne partie de l'isolation, mais le reste est exposé, et Java n'offre rien entre public et package-private.

C'est ici que JPMS entre en jeu.

Rappel JPMS

Le Java Platform Module System (JPMS), introduit avec Java 9, ajoute un niveau d'isolation au-dessus des packages : le module. Un module déclare explicitement ce qu'il exporte et ce dont il a besoin via un fichier module-info.java.

Le point clé : un module est fermé par défaut. Si un package n'est pas explicitement exporté, il est invisible depuis l'extérieur du module, même si les classes qu'il contient sont public.

C'est exactement le mécanisme qui nous manquait.

Réorganiser le package snapshot

Avant de mettre en place les modules, il y a un problème à régler dans l'organisation de l'application. Le package snapshot/ contient à la fois des types qui doivent être visibles des adapters (Snapshot, Action) et des types purement internes (CreationPoint, InvestedPoint, CreationPointConsumer, Recorder, SnapshotBuilder).

Or JPMS exporte des packages, pas des classes. Aucun moyen de dire « exporte Snapshot mais cache CreationPoint » s'ils sont dans le même package.

La solution est de séparer les deux catégories :

state/
├── CreationPoint.java          # interne - non exporté
├── CreationPointConsumer.java  # interne - non exporté
├── InvestedPoint.java          # interne - non exporté
├── Recorder.java               # interne - non exporté
├── SnapshotBuilder.java        # interne - non exporté
└── snapshot/
    ├── Snapshot.java           # exposé - les adapters en ont besoin
    └── Action.java             # exposé - fait partie du contrat

state décrit la mécanique interne de gestion d'état, tandis que state.snapshot contient ce qui en sort. Le package state n'est pas exporté, contrairement au package state.snapshot : les classes internes disparaissent pour le monde extérieur.

Le module application

Voici le module-info.java de l'application :

module be.charleshornick.supra {
    requires core; // Pragmatica-core

    exports be.charleshornick.supra; // ErrorCause, ForStoringSnapshot
    exports be.charleshornick.supra.create; // Port primaire
    exports be.charleshornick.supra.define; // ForLoadingSnapshot, ToCharacter
    exports be.charleshornick.supra.define.race; // Port primaire
    exports be.charleshornick.supra.define.profession; // Port primaire
    exports be.charleshornick.supra.define.characteristic; // Port primaire
    exports be.charleshornick.supra.retrieve.race; // Port primaire
    exports be.charleshornick.supra.retrieve.profession; // Port primaire
    exports be.charleshornick.supra.retrieve.snapshot; // Port primaire
    exports be.charleshornick.supra.race; // Vocabulaire
    exports be.charleshornick.supra.profession; // Vocabulaire
    exports be.charleshornick.supra.characteristic; // Vocabulaire
    exports be.charleshornick.supra.state.snapshot; // État exposé
}

Ce qui saute aux yeux : tout ce qui n'est pas listé est invisible. CreationPoint, InvestedPoint, CreationPointConsumer, Recorder, SnapshotBuilder sont public dans leur package, mais aucun autre module ne peut y accéder. Le compilateur refuse l'import.

Le public de l'article 2 n'est plus un passe-partout. Il signifie « public au sein du module ».

Pourquoi les exports ne sont pas qualifiés

Vous remarquerez qu'aucun export n'utilise la clause to :

// On ne fait PAS ceci :
exports be.charleshornick.supra.create to be.charleshornick.supra.bootstrap;

La raison est directement liée à Cockburn. L'application est « blissfully ignorant of the nature of the input device ». Si l'application déclare vers quels modules elle exporte, elle connaît ses consommateurs. C'est une forme de couplage, même si c'est au niveau du module et pas au niveau du code.

Un adapter JDBC aujourd'hui, un adapter MongoDB demain, un adapter in-memory pour les tests. L'application ne sait pas et ne doit pas savoir qui implémente ses ports secondaires, donc les exports restent ouverts.

Les adapters entrent en scène

Jusqu'ici dans la série, l'application vivait seule. Avec JPMS et les modules, il est temps d'introduire ce qui vit à l'extérieur.

L'adapter primaire

Un adapter primaire traduit les signaux d'un acteur externe vers les ports de l'application. Dans un système complet, ce serait un controller REST, une interface web, une CLI. Mais pour cet article, et en suivant l'approche que Cockburn utilise dans Hexagonal Architecture Explained, un adapter console de test suffit :

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

        [...]
    }
}

Cet adapter ne connaît que les ports primaires. Il ne peut pas instancier un SnapshotBuilder, ni accéder à un CreationPoint, ni même toucher un Recorder : JPMS l'interdit à la compilation.

Un point important sur les adapters primaires : ils sont façonnés par le consommateur qu'ils servent, pas par l'application. L'idée est la même que les BFF (Backend For Frontend) en microservices : un élément dédié à un front-end, développé par l'équipe qui connaît ce front-end. L'application derrière ne change pas, seule la traduction change.

Cela signifie que l'équipe front-end peut développer ses adapters en parallèle contre les ports, avec des fakes, sans être bloquée par le développement de l'application. Comme expliqué dans l'article 2, le port est une API, un contrat, pas un point de synchronisation.

L'adapter secondaire faké

Pour cet article, l'adapter secondaire est un fake in-memory situé dans le composition root, exactement comme le FixedTaxRateRepository que Cockburn utilise dans son livre : une implémentation triviale qui permet de tester le câblage sans 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());
    }
}

Il implémente le port secondaire ForStoringSnapshot. Il voit l'interface du port et le type Snapshot parce que ces packages sont exportés, mais il ne voit ni SnapshotBuilder, ni CreationPoint, ni quoi que ce soit d'autre. Il n'en a pas besoin, et JPMS s'assure qu'il ne le puisse pas.

Le Composition Root

Le composition root est le seul endroit qui voit tout : l'application, les adapters primaires, les adapters secondaires. C'est lui qui câble les implémentations ensemble et lance l'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();
    }
}

Pas de Spring, pas d'injection automatique, pas d'annotations — le câblage est explicite, lisible et vérifié à la compilation. Le composition root est le seul module avec une dépendance sur tous les autres, et c'est son rôle.

module-info.java du composition root

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

module-info.java de l'adapter de test

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

    exports be.charleshornick.supra.facade.test;
}

Les adapters exportent leur package pour que le composition root puisse les instancier. L'application ne sait même pas que ces modules existent.

Le piège : opens ... to

JPMS offre une directive opens qui autorise la réflexion sur un package. Et la tentation est réelle :

// NE FAITES PAS ÇA
module be.charleshornick.supra {
    opens be.charleshornick.supra.race to com.fasterxml.jackson.databind;
}

Cela permettrait à Jackson de désérialiser les types Race par réflexion. Pratique. Sauf que l'application vient de déclarer qu'elle connaît Jackson. Elle n'est plus « blissfully ignorant » de la technologie. La frontière est brisée, dans le module-info.java lui-même.

Et oui, Jackson est une technologie. Le choix d'utiliser Jackson plutôt que Gson appartient à l'adapter secondaire, pas à l'application.

La bonne approche, c'est que l'adapter secondaire gère la désérialisation. Il reçoit du JSON, construit une Race via le constructeur public, et la passe au port. L'application ne sait pas que le JSON existe quelque part. L'adapter fait son travail d'adapter.

@Transactional n'a pas sa place dans l'application

Puisqu'on parle d'adapters et de composition root, un point qui revient souvent dans les discussions : où placer les transactions ?

Certains soutiennent que @Transactional peut aller dans l'application (ce qu'ils appellent le « domaine ») parce que jakarta.transaction.Transactional est un standard Java et donc « pas spécifique à une technologie ».

C'est faux. jakarta.transaction.Transactional présuppose un gestionnaire de transactions, qui présuppose une persistance transactionnelle. L'application de Cockburn ne devrait même pas savoir qu'il y a une base de données. Une annotation transactionnelle dans l'application signifie que l'application sait qu'il y a une persistance transactionnelle derrière.

Mais au-delà de la théorie, il y a un argument pratique. Un même port primaire peut être câblé avec des adapters secondaires différents selon le contexte. Un adapter JDBC a besoin de transactions. Un adapter in-memory, non. Si @Transactional est dans l'application, il s'applique dans les deux cas, forçant une dépendance sur un gestionnaire de transactions qui pourrait ne même pas exister dans le contexte in-memory.

La décision transactionnelle dépend de la combinaison adapter primaire / adapter secondaire. Seul le composition root connaît cette combinaison. @Transactional va sur le handler dans l'adapter primaire, ou dans une configuration du composition root. Pas dans l'application.

L'application reste « blissfully ignorant » de tout.

Le compromis honnête : le constructeur public survit

JPMS ferme les packages non exportés. Le public d'un CreationPoint ou d'un SnapshotBuilder ne fuite plus vers les adapters, et c'est le gain principal.

Mais le constructeur public des ports primaires survit. CreateCharacter est dans un package exporté parce que l'adapter primaire doit le voir. Et comme discuté plus haut, JPMS exporte des packages, pas des classes : le constructeur public reste accessible à tout module qui dépend de l'application.

Un adapter qui fait new CreateCharacter(myFakeChecker, myFakeStorage) compile toujours.

On pourrait séparer le port et son constructeur en deux packages distincts, l'un exporté, l'autre non. Mais cela briserait la cohésion par cas d'usage de Brown, l'approche mise en place dans l'article 2. Le remède serait pire que le mal.

L'architecture guide, elle n'empêche pas. Aucun mécanisme technique ne remplacera la discipline d'une équipe qui comprend le pourquoi derrière la structure. JPMS + Brown + package-private couvrent la quasi-totalité de l'isolation. Le constructeur public reste un acte de confiance envers l'équipe.

Compromis de JPMS

Complexité du module-info.java

Plus l'application grandit, plus la liste d'exports s'allonge. C'est verbeux mais explicite, et un module-info.java de 30 lignes reste infiniment plus lisible qu'un setup ArchUnit avec 50 règles.

Un outillage pas toujours module-friendly

Certaines bibliothèques et certains outils ne sont pas encore prêts pour JPMS. C'est de moins en moins vrai avec les versions récentes de Java et les frameworks majeurs, mais c'est un point à vérifier avant adoption.

Des tests plus contraints

Les tests doivent respecter les mêmes frontières que le code de production. Un test ne peut pas accéder à un package non exporté. C'est une contrainte qui force à tester via les ports, ce qui est le bon niveau de test pour l'application. Les détails internes ne devraient pas être testés directement.

Spring Boot et JPMS

Historiquement, Spring Boot et JPMS ne faisaient pas bon ménage. Avec Spring Boot 4.x et Java 25, la situation s'est considérablement améliorée. Mais c'est un sujet à surveiller au moment d'ajouter de vrais adapters Spring dans les articles suivants.

Conclusion

L'article 2 posait le problème : package-private couvre une bonne partie de l'isolation mais public est un passe-partout. JPMS comble le manque en ajoutant un niveau de contrôle au-dessus.

Deux niveaux d'isolation, deux mécanismes, complémentaires :

  • JPMS contrôle ce qui sort du module. Les packages non exportés sont invisibles, même si les classes sont public.
  • package-private contrôle ce qui sort du package. Les classes internes restent invisibles au sein du module lui-même.

Brown organise l'intérieur via package-private, qui applique au niveau du package. JPMS applique au niveau du module. Cockburn protège la frontière entre l'intérieur et l'extérieur.

L'application est désormais structurée, isolée et vérifiable à la compilation. Mais nous n'avons pas encore parlé de tests. Comment garantir que les ports primaires fonctionnent correctement, que les adapters secondaires respectent leur contrat, et que tout tient ensemble sans duplication de tests ?

C'est le sujet de l'article 4 : tester en P&A.

L'application d'exemple évolue depuis l'article 2 avec l'ajout des modules JPMS, d'un adapter console de test, d'un adapter in-memory et d'un composition root. L'application elle-même n'a pas changé, seule la structure du projet a évolué.
Stack : Java 25, Pragmatica (Result<T>, Option<T>), JUnit 6, AssertJ, Maven. Pas de Spring dans cet article.

Cet article fait partie de la série « Ports & Adapters: Beyond the Theory ». La série a été initiée après qu'Alistair Cockburn, créateur du pattern Ports & Adapters et co-auteur du Manifeste Agile, a relayé le premier article en décrivant l'approche comme « an amazing use of Hexagonal Architecture ».

Les décisions architecturales de cette série s'appuient sur l'article original de Cockburn (2005) et sur Hexagonal Architecture Explained (Cockburn & Garrido de Paz, 1re édition mise à jour, 2025).