Deuxième article de la série « Ports & Adapters: Beyond the Theory ». L'article 1 démontrait le hot-swap d'adapters à runtime en exploitant les avantages du pattern P&A. Celui-ci s'attaque à ce qu'Alistair Cockburn a délibérément laissé ouvert : comment organiser l'intérieur de l'application. Chaque ligne de code du repo compagnon a été écrite à la main. Aucun code généré par IA.
Code source complet : GitHub — branche article/2-package-by-component
« I need to be assured that certain interests are package-private » — Arthur Case, à propos de l'application.
L'application n'est pas protégée
Soyons clairs, le pattern Ports & Adapters d'Alistair Cockburn se résume aux points suivants :
- le système est découpé en deux parties : l'intérieur (l'application) et l'extérieur (les adapters) ;
- l'application se concentre uniquement sur les besoins métier ;
- l'application communique avec le monde extérieur via des ports ;
- les adapters traduisent et communiquent avec l'application via ces ports.
Voilà, aussi simple que ça en a l'air.
Imaginez un laptop qui n'a que des ports USB-C. Vous voulez brancher votre souris filaire, mais elle est en USB-A. Pour permettre la communication, il vous faut un adaptateur USB-A vers USB-C. Désormais, votre clic de souris, en passant par l'adaptateur, est compris par le laptop parce que la connexion est établie.
(L'analogie est nettement plus difficile à faire avec le terme « architecture hexagonale »)
Malheureusement, si P&A garantit une séparation effective entre l'intérieur et l'extérieur, il ne garantit absolument pas que l'intérieur soit correctement organisé.
Or que voit-on dans les tutoriels et les billets de blog ? Que l'organisation est presque toujours la même :
domain/
├── model/
├── port/
│ ├── in/
│ └── out/
└── service/
Et avec une telle approche, deux choses sautent immédiatement aux yeux :
domainest utilisé alors que c'est un terme du DDD (Domain-Driven Design), jamais employé par Alistair dans ses écrits, où il parle d'Application ;
« The application is everything on the inside, everything that isn't technology-specific. »
- tout finit en
publicpour que chaque élément puisse accéder à ce dont il a besoin pour fonctionner.
Et ce dernier point est le plus problématique. Tout l'intérêt d'avoir un intérieur et un extérieur, c'est que ce qui est à l'intérieur, et n'a aucune raison de fuiter, y reste. Or avec ce mot-clé, tout ce qui devrait rester interne devient accessible à n'importe quel adapter.
Si l'article 1 a montré une chose, c'est qu'il est possible de swapper un adapter à la volée avec zéro impact sur l'application, tout simplement parce qu'elle ne le sait pas et ne s'en soucie pas.
« Remember, in Ports & Adapters you are free to organize the inside of the app in any way you like, and the things outside the app in any way you like. Just put ports in place. »
Très bien, « just put ports in place ». Mais que sont-ils exactement ?
Il existe deux types de ports qu'il est important d'expliquer pour bien comprendre leur rôle.
Les ports primaires
Ces ports représentent la façon dont l'application veut qu'on lui parle. Tout adapter qui s'adresse à un port primaire devient par définition un adapter primaire et suit un protocole précis.
Comme le dit Alistair :
« The protocol for a port is given by the purpose of the conversation between the two devices. The protocol takes the form of an application program interface (API). »
Dans le cas des ports primaires, ce protocole représente l'action que l'adapter veut voir exécutée quand il utilise l'application.
Sauf que quand on regarde les articles, les conférences et les tutoriels, ce protocole se résume souvent à une interface Java. Pire : cette interface tend à devenir un fourre-tout.
Pourtant, dans l'édition mise à jour de Hexagonal Architecture Explained (2025), Alistair explique qu'un port peut être une interface ou une classe.
« In your life, decide which way you prefer to write. »
Dans cette implémentation, les ports primaires sont des classes concrètes final qui représentent l'API, et offrent un protocole qui rend explicite la conversation entre l'adapter et l'application.
Les ports secondaires
De leur côté, les ports secondaires restent des interfaces au sens Java.
La raison est simple :
« A port identifies a purposeful conversation. There will typically be multiple adapters for any one port, for various technologies that may plug into that port. »
L'application établit une conversation avec un adapter dont elle ne sait rien. Si elle utilisait autre chose qu'une interface, cela voudrait dire qu'elle devient partie prenante de l'implémentation, brisant la frontière.
Et le reste de l'application ?
Si les ports sont parfaitement justifiés à être public, il n'en va pas de même pour tous les autres éléments qui composent l'application.
Ce qui nous ramène au point soulevé plus haut : les adapters primaires et secondaires ne peuvent utiliser que ce que l'application leur permet d'utiliser, au nom des conversations/protocoles.
Sauf que cet aspect n'est renforcé que si les adapters s'appuient uniquement sur la conversation définie, pas sur les rumeurs de couloir qu'un malheureux mot-clé a rendues public.
Mais comment protéger l'intérieur ?
C'est ici que Simon Brown entre en scène
Dans « The Missing Chapter » du livre Clean Architecture, Simon Brown propose une approche différente, en opposition à l'approche par couches comme, justement, à Ports & Adapters.
Mais si, au lieu de les opposer, l'application utilisait les deux de façon complémentaire — P&A la protégeant des adapters, Brown la protégeant d'elle-même ?
Là où P&A fournit les frontières qui protègent l'application, le package-by-component fournit l'organisation interne qui lui manque.
L'idée est simple : au lieu d'organiser le code par couche technique (model/, port/, service/), le code est organisé par cas d'usage et par composant. Le package-private de Java devient le niveau de visibilité par défaut : ce qui n'a aucune raison d'être public ne l'est pas.
Dans le code d'exemple, l'application est elle-même le composant, transformant le package-by-component en package-by-use-case : la même idée, appliquée au bon niveau de granularité.
Voici l'organisation de l'application, avec une brève explication de chaque élément :
be.charleshornick.supra
├── ErrorCause.java # constantes d'erreur transverses
├── ForStoringSnapshot.java # port secondaire, partagé entre cas d'usage
│
├── create/ # cas d'usage : créer un personnage
│ ├── CreateCharacter.java # port primaire (public final)
│ ├── ForCheckingNameUnicity.java # port secondaire (spécifique)
│ ├── Character.java # package-private
│ └── CharacterNameValidator.java # package-private
│
├── define/ # cas d'usage : modifier un personnage existant
│ ├── ForLoadingSnapshot.java # port secondaire, partagé dans define/
│ ├── ToCharacter.java # interface d'étape, partagée dans define/
│ ├── race/
│ │ ├── DefineRace.java # port primaire (public final)
│ │ ├── ForLoadingRace.java # port secondaire (spécifique)
│ │ ├── DefineRaceStep.java # package-private
│ │ └── Character.java # package-private
│ ├── profession/
│ │ ├── DefineProfession.java # port primaire (public final)
│ │ ├── ForLoadingProfession.java # port secondaire (spécifique)
│ │ ├── DefineProfessionStep.java # package-private
│ │ └── Character.java # package-private
│ └── characteristic/
│ ├── DefineCharacteristic.java # port primaire (public final)
│ ├── AddOnePoint.java # package-private (step builder)
│ ├── RemoveOnePoint.java # package-private (step builder)
│ └── Character.java # package-private
│
├── retrieve/ # cas d'usage : lecture
│ ├── race/
│ │ ├── ForGettingRaces.java # port secondaire (spécifique)
│ │ ├── GetAllRaces.java # port primaire (public final)
│ ├── profession/
│ │ ├── ForGettingProfessions.java # port secondaire (spécifique)
│ │ ├── GetAllProfessions.java # port primaire (public final)
│ └── snapshot/
│ ├── ForGettingSnapshot.java # port secondaire (spécifique)
│ ├── GetAllSnapshots.java # port primaire (public final)
│ └── GetLatestSnapshot.java # port primaire (public final)
│
├── race/ # vocabulaire : ce qu'EST une race
│ ├── Race.java
│ └── RaceName.java
├── profession/ # vocabulaire : ce qu'EST une profession
│ ├── Profession.java
│ ├── ProfessionName.java
│ ├── ProfessionType.java
│ └── Prerequisite.java
├── characteristic/ # vocabulaire : ce qu'EST une caractéristique
│ ├── PrimaryCharacteristic.java
│ └── PrimaryCharacteristicName.java
└── snapshot/ # mécanique interne de gestion d'état
├── Snapshot.java
├── SnapshotBuilder.java
├── Action.java
├── Recorder.java
├── CreationPoint.java
├── CreationPointConsumer.java
└── InvestedPoint.java
Deux catégories de packages sont visibles :
- les actions (
create/,define/,retrieve/) décrivant ce qu'on peut faire ; - le vocabulaire (
race/,profession/,characteristic/,snapshot/) décrivant ce que les choses sont.
La première contient toute la logique métier, cachée derrière package-private, tandis que la seconde contient les types partagés qui définissent le langage de l'application.
Cette isolation par cas d'usage maintient le risque d'effets de bord au plus bas, puisque chaque élément interne ne sert que son propre cas. C'est le principe de cohésion du code : ce qui change ensemble vit ensemble. Modifier un cas d'usage ne touche qu'un seul package.
Prenons un exemple concret de ce que cette isolation apporte : la classe Character est présente dans les 3 sous-packages de define/, mais son rôle est différent à chaque fois.
En suivant le modèle d'organisation évoqué plus haut, une seule classe aurait existé dans models/ et aurait exposé autant de méthodes qu'il y a de cas d'usage.
Rien de mal à ça en soi, mais modifier cette classe pour un cas d'usage risque de casser les autres. L'approche de Brown rend cela impossible.
Maintenant que la structure est en place, regardons ce qu'elle contient, en commençant par ce qui saute aux yeux dans l'arborescence : les noms des ports.
Le nommage des ports
Comme évoqué plus haut, Alistair explique que les ports tiennent des conversations précises avec les adapters.
Un port secondaire représente la demande que le cas d'usage adresse au monde extérieur. Pour le refléter, j'ai suivi la convention de nommage promue par Cockburn : ForDoingSomething.
Chaque port secondaire ouvre une conversation avec un besoin précis, une action qu'il veut voir exécutée. Ces ports ne sont toutefois pas placés dans un package figé mais au plus près de l'endroit où ils sont utilisés.
Par exemple, ForCheckingNameUnicity est dans le package de création de personnage parce que c'est le seul endroit où il est utilisé. Inversement, ForLoadingSnapshot est à la racine de l'action define/ parce qu'il est utilisé par tous ses sous-packages.
Les ports secondaires sont des interfaces fonctionnelles pour permettre une granularité plus fine dans les conversations. Ces ports secondaires n'ont pas, et ne peuvent pas avoir, de méthodes default, pour les raisons évoquées plus haut.
De leur côté, les ports primaires ne demandent pas, ils sont les actions elles-mêmes, et les exposent via des protocoles. Leur nommage le reflète : chaque étape du protocole est explicite afin de former un protocole clair.
Mais le nommage n'est pas la seule raison de construire les protocoles ainsi : cette écriture garantit aussi à la compilation que le port est correctement appelé.
Le Step Builder renforce le protocole
Prenons l'exemple suivant :
defineCharacteristic
.byAddingOnePoint()
.toCharacteristicNamed(CharacteristicName.COURAGE)
.toCharacterNamed("Borgrim");
Chaque étape affine le protocole en le rendant plus précis sur l'action qu'il va exécuter. L'action se lit naturellement : définir une caractéristique en ajoutant un point à celle nommée courage, sur le personnage nommé Borgrim.
Mais surtout, il est impossible de l'appeler dans un ordre différent : le compilateur bloque automatiquement tout ce qui n'est pas explicitement permis par le protocole.
Pour le garantir, le pattern Step Builder est utilisé. En regardant DefineCharacteristic, on voit que deux méthodes sont exposées : byAddingOnePoint et byRemovingOnePoint, chacune retournant une nouvelle instance d'une classe package-private implémentant DefineCharacteristic.ToCharacteristic.
Puisque le type de retour de ces méthodes est DefineCharacteristic.ToCharacteristic, la prochaine méthode disponible est nécessairement toCharacteristicNamed(String).
De plus, cette même méthode retourne l'interface ToCharacter, que les mêmes classes package-private implémentent, complétant le protocole sans jamais permettre d'en contourner le comportement défini.
public final class DefineCharacteristic {
public interface ToCharacteristic {
ToCharacter toCharacteristicNamed(PrimaryCharacteristicName characterName);
}
private final ForLoadingSnapshot forLoadingSnapshot;
private final ForStoringSnapshot forStoringSnapshot;
public DefineCharacteristic(final ForLoadingSnapshot forLoadingSnapshot, final ForStoringSnapshot forStoringSnapshot) {
this.forLoadingSnapshot = forLoadingSnapshot;
this.forStoringSnapshot = forStoringSnapshot;
}
public ToCharacteristic byAddingOnePoint() {
return new AddOnePoint(this.forLoadingSnapshot, this.forStoringSnapshot);
}
public ToCharacteristic byRemovingOnePoint() {
return new RemoveOnePoint(this.forLoadingSnapshot, this.forStoringSnapshot);
}
}
Cette approche va plus loin que la simple écriture façon protocole : le port est singleton et stateless.
En effet, chaque appel aux méthodes exposées crée une nouvelle instance d'étape éphémère, rendant le pattern thread-safe. Puisque l'instance ne vit que le temps de l'appel, elle est immédiatement collectée par le garbage collector une fois l'appel terminé.
Notez que le Step Builder n'est pas systématique.
createCharacter.named("Borgrim");
Ici, la méthode exposée par le port suffit au protocole. Le pattern sert l'application, pas l'inverse.
Une note sur Result<T>
En parcourant le code, vous aurez remarqué que les ports retournent systématiquement Result<T>, et que les tests utilisent .onSuccess() et .onFailure().
J'utilise la bibliothèque Pragmatica (Apache 2.0, Java 25+) pour ne jamais lancer d'exception dans l'application. Ce choix simplifie considérablement la gestion d'erreurs et les tests, mais c'est un sujet assez riche pour mériter son propre article dans cette série.
Testabilité
Un des bénéfices directs de cette organisation est la testabilité. L'application est du pur POJO, aucun framework n'est nécessaire pour la tester.
Puisque les ports secondaires sont des interfaces fonctionnelles, une lambda suffit pour créer un fake :
@Test
@DisplayName("Succeed when the name is available")
void succeedWhenNameIsFreeToUse() {
new CreateCharacter(_ -> true, Result::ok)
.named("Borgrim")
.onSuccess(snapshot ->
assertThat(snapshot).isEqualTo(SnapshotFixture.getDefaultOne()))
.onFailure(cause ->
fail("Cannot create new character: " + cause.message()));
}
_ -> true signifie que le nom est disponible. Result::ok signifie que le stockage réussit toujours. Pas de Mockito, pas de framework de mock, pas de when().thenReturn(). Le comportement est explicite, lisible, et vérifié à la compilation : si la signature d'un port change, la lambda casse, là où un mock Mockito peut continuer à compiler silencieusement.
Le Step Builder se teste de la même manière :
new DefineRace(forLoadingSnapshot, forStoringSnapshot, forLoadingRace)
.named(RaceName.ELF)
.toCharacterNamed("Borgrim")
.onSuccess(snapshot -> assertEqualsAgainst(snapshot, expected))
.onFailure(cause -> fail("Failed to define new race: " + cause.message()));
Pas de setup, pas de teardown, pas de contexte Spring à charger. On instancie le port avec des fakes, on appelle le protocole, on vérifie le résultat. Le test se lit comme une spécification.
L'approche de test complète, y compris le partage de scénarios entre tests unitaires et tests d'intégration, sera détaillée dans l'article 4.
Le compromis honnête : public nous trahit
Tout ce qui a été montré jusqu'ici fonctionne :
package-privatecache les éléments internes ;- le Step Builder impose le protocole ;
- les ports sont
final; - aucun framework ne s'infiltre dans l'application.
Mais il y a un trou, parce que les constructeurs des ports primaires sont public :
public final class CreateCharacter {
public CreateCharacter(
final ForCheckingNameUnicity forCheckingNameUnicity,
final ForStoringSnapshot forStoringSnapshot) {
// ...
}
}
Et c'est intentionnel, ça doit l'être. Un composition root, qui vivra dans un autre package et dans un autre module, devra instancier cette classe et injecter les implémentations des ports secondaires. Sans constructeur public, c'est impossible.
Sauf que cela signifie aussi que n'importe quel adapter peut contourner le câblage prévu et faire :
var rogue = new CreateCharacter(myOwnChecker, myOwnStorage);
Le même problème existe pour SnapshotBuilder. Il est public parce que les classes Character, qui sont package-private dans chaque sous-package de define/, doivent l'appeler depuis leurs méthodes doSnapshot(). Des packages différents, donc public obligatoire. Et pourtant, aucun adapter n'a la moindre raison légitime de toucher SnapshotBuilder.
Le modèle de visibilité de Java nous donne deux options : public (tout le monde le voit) et package-private (seul le même package le voit), rien entre les deux. Aucun moyen de dire « ce constructeur n'est visible que pour tel module » ou « cette classe n'est accessible que depuis l'intérieur de l'application ».
L'approche de Brown avec package-private couvre une bonne partie de l'isolation dont nous avons besoin. Le reste, comme les constructeurs public et les classes utilitaires partagées, demeure exposé.
C'est une limite du langage, pas du pattern.
Compromis
Aucune approche n'est parfaite, et organiser par cas d'usage avec Ports & Adapters vient avec son lot de compromis à connaître.
Le nombre de fichiers augmente
Chaque cas d'usage a son propre package avec sa propre classe Character, son propre step builder, ses propres ports. Naviguer dans l'IDE demande de connaître la structure pour trouver vite.
La courbe d'apprentissage existe
Les développeurs habitués aux packages service/ et repository/ doivent changer de modèle mental. La séparation entre actions et vocabulaire n'est pas intuitive pour tout le monde au début.
Risque de rigidité de l'application
Appliquée sans discernement, l'approche peut devenir rigide. Un cas d'usage trivial, comme une simple lecture qui passe directement au port secondaire, ne justifie pas nécessairement un package dédié avec quatre classes.
Risque de duplication
Deux cas d'usage qui auraient besoin du même type interne doivent soit l'extraire dans un package de vocabulaire, soit accepter la duplication de code.
Ces compromis sont réels et doivent être pesés. Mais ils doivent être mis en balance avec l'alternative : un package domain/ où tout est public, où rien n'est imposé, et où la promesse d'isolation de Ports & Adapters repose uniquement sur la bonne volonté de l'équipe.
Conclusion
Cockburn protège l'application du monde extérieur. Brown protège l'application d'elle-même. Ensemble, ils offrent une application structurée, isolée et testable sans aucun framework.
Mais Java nous laisse un trou : le mot-clé public est un passe-partout qui ne peut pas être révoqué au niveau du package. Des classes internes qui ne devraient être utilisées qu'au sein de l'application sont accessibles à tous.
Il nous faut un mécanisme d'application au-delà de package-private.
C'est le sujet de l'article 3 : JPMS.
L'application d'exemple implémente un système de création de personnage de JDR avec de vraies règles métier : création, définition de race et de profession avec contraintes bidirectionnelles, et investissement de points dans les caractéristiques primaires avec limites dépendant de la race.
Result<T>, Option<T>), JUnit 6, AssertJ, Maven. Pas de Spring. Pas de framework. Pas d'annotations.
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).