🎯 OBJECTIF
Comprendre comment :
LayeredArchitectureTest complet qui casse le build dès qu'un package en appelle un qu'il n'a pas le droit d'appeler.class au verdict, sans démarrer l'applicationconsideringAllDependencies vs …InLayers vs …InAnyPackage) — le choix qui décide si le test protège vraiment quelque chosenoClasses().that()…should()…) en architecture déclarée, et l'inverse🧠 MODÈLE MENTAL
Dans la plupart des applications Java, le découpage en couches est une règle que personne ne peut faire respecter. Le compilateur ne connaît pas vos couches : si le package est visible, l'import compile. La règle n'existe donc que dans un schéma et dans la mémoire de l'équipe — et elle s'efface un raccourci à la fois, sans que rien ne prévienne.
ArchUnit change ça. Il lit les classes déjà compilées, en déduit qui utilise qui, et compare ce résultat à ce que vous avez déclaré. layeredArchitecture() sert exactement à ce besoin : vous listez vos couches, puis vous dites pour chacune qui a le droit de l'utiliser. Le jour où quelqu'un franchit la limite, le test devient rouge et le build s'arrête. Aucune application n'est démarrée, aucune base n'est nécessaire : tout se joue sur les fichiers .class, en quelques centaines de millisecondes.
LayeredArchitectureTest est un test JUnit qui vérifie qui a le droit d'appeler qui entre les packages de votre application. Il ne teste aucun comportement métier : il teste la structure du code. S'il passe au rouge, c'est qu'une classe utilise une classe qu'elle n'a pas le droit d'utiliser.
Une application en couches repose sur une règle simple : le web appelle le métier, le métier appelle la persistance, et jamais l'inverse. Cette règle n'existe nulle part dans le code. Concrètement, rien n'empêche d'écrire ceci :
// OrderRepository — classe de la couche persistence
public class OrderRepository {
private final PricingService pricingService; // ⚠️ la persistance utilise le métier :
// sens interdit, mais ça compile et ça part en prod
}javaLe scénario est toujours le même. Il faut enrichir une entité au chargement, le calcul existe déjà dans PricingService, l'injecter prend trente secondes et le faire proprement une demi-journée. La PR passe : le relecteur regarde la logique, pas le sens des dépendances. Trois mois plus tard il y a quarante cas comme celui-là, et les dégâts sont structurels :
service et persistence s'appellent mutuellement — plus aucune des deux ne se comprend seule ;La cause n'est pas l'incompétence : c'est que le seul garde-fou est humain, tardif et facultatif. La relecture arrive après l'écriture, dépend de qui est disponible, et met le relecteur dans une position inconfortable — bloquer une PR qui fonctionne pour une question de structure, c'est passer pour tatillon face à un collègue pressé. Dans le doute, on laisse passer.
LayeredArchitecture apporte| Problème | Réponse d'ArchUnit |
|---|---|
| La règle n'existe que dans un schéma qui se périme | Elle est écrite en Java, dans le repo, versionnée avec le code qu'elle décrit |
| On ne découvre le problème que le jour où il coûte cher | Le test échoue au commit suivant, en donnant le fichier et la ligne |
| Le garde-fou dépend d'un relecteur attentif | Le garde-fou est la CI : systématique et impersonnel |
| Refuser une PR pour une question de structure est inconfortable | Ce n'est plus une personne qui refuse mais le build : la discussion devient « faut-il changer la règle ? », à froid et en dehors de la PR |
| Réparer coûte plus cher chaque mois | Ce qui existe peut être gelé, et le nombre de problèmes ne peut plus que baisser (§9) |
| Écrire une interdiction à la fois est long et incomplet | Un langage dédié : on déclare les couches et qui accède à qui, ArchUnit en déduit les vérifications (§🔟 pour la forme manuelle) |
Le tout pour une classe de test, quelques centaines de millisecondes, et aucune infrastructure : pas de plugin de build, pas de serveur d'analyse, pas d'application démarrée.
À dire tout de suite, pour éviter le faux sentiment de sécurité :
getBean("nom"), requêtes JPQL en String, câblage par configuration : invisibles (§7).🔑 Conclusion clé
Un test d'architecture ne rend pas le code meilleur. Il rend le dérapage visible et bloquant au moment où il arrive, au lieu de discret et gratuit pendant deux ans. C'est un outil qui conserve une décision, pas un outil qui la prend.
.class produits par javac. C'est le code intermédiaire exécuté par la JVM ; il conserve les types, les signatures, les appels et les annotations, ce qui suffit à reconstituer les dépendances.JavaClasses — le résultat de la lecture : toutes les classes analysées en mémoire, chacune connaissant ses champs, ses méthodes, et qui elle appelle / qui l'appelle.Dependency — l'unité que vérifie ArchUnit : « la classe A utilise la classe B », avec la raison précise (appel de méthode, type de paramètre, annotation…).ArchRule — une règle exécutable : rule.check(javaClasses) lève une AssertionError s'il y a des infractions. LayeredArchitecture est une ArchRule.ArchCondition — la brique élémentaire (« ne doit pas être vide », « ne doit être utilisée que par… ») qu'une ArchRule applique à chaque élément...service..). Rien de plus : ArchUnit ne devine aucune hiérarchie, c'est vous qui la déclarez... remplace n'importe quel nombre de packages, * un seul segment. ..web.. correspond aussi bien à com.app.web.dto qu'à com.app.module.web.FreezingArchRule — un emballage de règle qui note les infractions déjà présentes dans un fichier versionné, et ne fait échouer que les nouvelles.ArchUnit se branche sur JUnit 5 via son propre moteur de test : un artefact, aucune configuration.
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.4.2</version> <!-- ✓ tire archunit + le moteur JUnit 5 -->
<scope>test</scope>
</dependency>xml// Gradle (Kotlin DSL)
testImplementation("com.tngtech.archunit:archunit-junit5:1.4.2")kotlinLe squelette minimal : une annotation sur la classe dit quoi analyser, des champs static annotés disent quoi vérifier.
package com.myapp.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
@AnalyzeClasses(
packagesOf = MyAppApplication.class, // ✓ resiste au renommage : pas de package en String
importOptions = ImportOption.DoNotIncludeTests.class // ⚠️ indispensable, voir §8
)
class LayeredArchitectureTest {
@ArchTest
static final ArchRule layers = /* … */;
}javaTrois points non négociables :
static — ArchUnit ne lit que les champs statiques annotés @ArchTest. Un champ d'instance est ignoré : le test est vert, et il ne vérifie rien.packagesOf vaut mieux que packages = "com.myapp" : si le package est déplacé, la compilation casse au lieu de laisser une analyse vide.DoNotIncludeTests exclut target/test-classes. Sinon vos classes de test sont analysées elles aussi, alors qu'elles n'appartiennent à aucune couche.🔑 Conclusion clé
@AnalyzeClasses définit le périmètre (quelles classes sont lues), @ArchTest définit les règles. Les deux sont indépendants : une même classe de test peut porter dix règles évaluées sur une seule lecture — c'est ce qui rend ArchUnit peu coûteux à étendre.
Prenons une application en quatre couches : web (contrôleurs REST), service (métier), persistence (repositories JPA), domain (le modèle, qui ne doit dépendre de rien).
package com.myapp.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
@AnalyzeClasses(
packagesOf = MyAppApplication.class,
importOptions = ImportOption.DoNotIncludeTests.class
)
class LayeredArchitectureTest {
@ArchTest
static final ArchRule layers_are_respected = layeredArchitecture()
// 1. Which dependencies are even looked at (see §6) — must come first.
.consideringOnlyDependenciesInAnyPackage("com.myapp..")
// 2. Declare the layers: a layer is just a set of classes.
.layer("Web").definedBy("com.myapp..web..")
.layer("Service").definedBy("com.myapp..service..")
.layer("Persistence").definedBy("com.myapp..persistence..")
.layer("Domain").definedBy("com.myapp..domain..")
.optionalLayer("Config").definedBy("com.myapp.config..") // may be empty, see §8
// 3. Who may use each layer ("who is allowed to call me").
.whereLayer("Web").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Web", "Config")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service", "Config")
// 4. What a layer may use ("whom am I allowed to call") — keeps the domain pure.
.whereLayer("Domain").mayNotAccessAnyLayer()
// 5. One documented, deliberate exception.
.ignoreDependency(
com.myapp.config.LegacyExportJob.class,
com.myapp.persistence.OrderRepository.class)
// 6. Every analysed class must belong to a layer — no blind spot left.
.ensureAllClassesAreContainedInArchitectureIgnoring("com.myapp.support..")
.because("les couches sont le contrat de maintenabilité de l'application : "
+ "toute exception doit être discutée, pas improvisée");
}javaC'est le point le plus mal compris du langage. Une dépendance va d'une classe d'origine vers une classe cible (OrderRepository → PricingService). ArchUnit vous laisse écrire la contrainte des deux côtés : soit « personne d'autre que X ne peut m'utiliser » (vu de la cible), soit « je ne peux utiliser que X » (vu de l'origine).
| Méthode | Point de vue | Ce qu'elle interdit |
|---|---|---|
mayOnlyBeAccessedByLayers("A", "B") |
la cible : qui peut m'utiliser | qu'une classe située hors de A, B (et de la couche elle-même) utilise cette couche |
mayNotBeAccessedByAnyLayer() |
la cible | que quiconque utilise cette couche — typiquement Web, qui est tout en haut |
mayOnlyAccessLayers("A", "B") |
l'origine : qui je peux utiliser | que cette couche utilise autre chose que A et B |
mayNotAccessAnyLayer() |
l'origine | que cette couche utilise quoi que ce soit — typiquement Domain |
Deux points qui évitent 90 % des malentendus :
Service qui appelle un autre Service ne viole jamais rien, quelle que soit la déclaration.whereLayer n'est contrainte par rien. layer("Domain").definedBy(…) seul ne fait qu'agrandir le périmètre analysé : aucune vérification n'en découle. C'est le cas le plus fréquent de test vert qui ne teste rien.Le schéma des dépendances autorisées et interdites de la règle ci-dessus :
flowchart TD
W["Web<br/>..web.."]
S["Service<br/>..service.."]
P["Persistence<br/>..persistence.."]
D["Domain<br/>..domain.."]
W -->|"✓ autorisé"| S
S -->|"✓ autorisé"| P
S -->|"✓ autorisé"| D
P -->|"✓ autorisé"| D
P -.->|"✗ Service.mayOnlyBeAccessedByLayers(Web, Config)"| S
P -.->|"✗ Web.mayNotBeAccessedByAnyLayer()"| W
D -.->|"✗ Domain.mayNotAccessAnyLayer()"| Pmermaidjava.lang.AssertionError: Architecture Violation [Priority: MEDIUM] -
Rule 'Layered architecture considering only dependencies in any package ['com.myapp..'],
consisting of
layer 'Web' ('com.myapp..web..')
layer 'Service' ('com.myapp..service..')
layer 'Persistence' ('com.myapp..persistence..')
where layer 'Service' may only be accessed by layers ['Web', 'Config']
because les couches sont le contrat de maintenabilité de l'application […]'
was violated (2 times):
Method <com.myapp.persistence.OrderRepository.enrich(…)>
calls method <com.myapp.service.PricingService.priceOf(…)>
in (OrderRepository.java:47)
Constructor <com.myapp.persistence.OrderRepository.<init>(com.myapp.service.PricingService)>
has parameter of type <com.myapp.service.PricingService>
in (OrderRepository.java:0)Le message contient la règle entière (donc le because), le nombre d'infractions, et pour chacune le membre fautif avec le fichier et la ligne. C'est cliquable dans l'IDE : celui qui casse la règle sait quoi corriger sans lire cette note.
Utile dans un script, un test paramétré, ou quand l'analyse doit être partagée avec autre chose :
@Test
void layers_are_respected() {
JavaClasses classes = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("com.myapp");
layeredArchitecture()
.consideringOnlyDependenciesInAnyPackage("com.myapp..")
.layer("Web").definedBy("com.myapp..web..")
// …
.check(classes); // ⚠️ throws AssertionError on violation
}javaOn y perd la mise en cache de la lecture et le rapport règle par règle : à réserver aux cas où @AnalyzeClasses ne suffit pas.
.class au build rougeflowchart LR
A["target/classes<br/>fichiers .class"] -->|"ASM : lecture, pas d'exécution"| B["ClassFileImporter<br/>+ ImportOption"]
B --> C["JavaClasses<br/>toutes les classes et leurs dépendances"]
C --> D["LayeredArchitecture<br/>= une ArchRule composée"]
D --> E["EvaluationResult<br/>liste des infractions"]
E -->|"vide"| F["✓ test vert"]
E -->|"non vide"| G["AssertionError<br/>✗ build rouge"]mermaidÉtape par étape :
packages / packagesOf en emplacements concrets (répertoires, JARs), puis applique les ImportOption pour en écarter une partie.ClassFileImporter lit les octets avec ASM. Aucune classe n'est chargée dans la JVM : pas de bloc statique exécuté, pas de NoClassDefFoundError sur une dépendance absente, pas d'application démarrée. C'est pourquoi un test d'architecture est aussi rapide qu'un test unitaire.resolveMissingDependenciesFromClassPath (défaut : true). Conséquence : si une classe parente n'est pas analysée, certaines dépendances peuvent manquer.LayeredArchitecture se transforme en une série de conditions : chaque whereLayer(...) devient l'équivalent d'un classes().that().resideInAnyPackage(motifs).should().onlyHaveDependentClassesThat()…, plus une condition « cette couche n'est pas vide » pour chaque layer() non optionnelle.evaluate() renvoie un EvaluationResult ; s'il contient des infractions, check() lève une AssertionError — donc un test rouge, donc un build rouge. Aucun plugin de build : c'est un test comme les autres.⚡ Mise en cache de la lecture
Par défaut (CacheMode.FULL), les classes lues sont gardées en cache par emplacement et réutilisées d'une classe de test à l'autre, via des références faibles (le GC peut les libérer si la mémoire manque). Dix classes de test qui analysent le même package ne lisent le bytecode qu'une fois. cacheMode = CacheMode.PER_CLASS limite le cache à la classe de test en cours et libère la mémoire ensuite — utile sur un très gros projet.
C'est le premier appel de la chaîne, et le plus lourd de conséquences. Il ne change pas les couches : il change quelles dépendances sont soumises au test.
| Mode | Dépendances regardées | Effet concret |
|---|---|---|
consideringOnlyDependenciesInLayers() |
seulement celles dont l'origine ET la cible sont dans une couche déclarée | Le plus permissif. Tout ce qui vit hors des couches (config, MyAppApplication, mapper, util) devient invisible : une classe de config qui appelle directement un repository ne déclenche rien. |
consideringOnlyDependenciesInAnyPackage("com.myapp..") |
celles dont origine et cible sont dans les packages listés | Le réglage recommandé. Tout votre code compte, y compris les classes hors couches ; le JDK et les bibliothèques externes sont ignorés. |
consideringAllDependencies() |
absolument toutes — « même une dépendance vers Object peut provoquer une violation » |
Le plus strict, et un piège : avec mayOnlyAccessLayers("Service"), un simple String en paramètre fait échouer la règle, puisque java.lang.String n'est dans aucune couche autorisée. |
⚠️ Le mode change de sens selon le côté où vous écrivez la règle
consideringAllDependencies() n'a pas le même effet des deux côtés :
mayOnlyBeAccessedByLayers), il est presque sans risque : le JDK n'utilise jamais votre code, donc seules vos propres classes hors couches apparaissent — et c'est justement le trou qu'on veut boucher ;mayOnlyAccessLayers, mayNotAccessAnyLayer), il est inutilisable sans une liste blanche interminable, puisque chaque type du JDK compte comme une dépendance interdite.D'où la recommandation : consideringOnlyDependenciesInAnyPackage("<racine du projet>..") se comporte de façon prévisible dans les deux sens. Réservez consideringAllDependencies() aux règles écrites du côté cible, et évitez consideringOnlyDependenciesInLayers() sauf pour un premier essai exploratoire.
Une dépendance, au sens d'ArchUnit, c'est tout ce que le bytecode montre :
@Valid(groups = OrderChecks.class)) ;instanceof, les casts, les références de classe (Foo.class), les lambdas et références de méthode.🕳️ Les angles morts — à connaître avant de faire confiance au vert
Rien de ce qui suit n'apparaît dans le bytecode ; le test reste vert alors que la règle est violée à l'exécution :
Class.forName("com.myapp.persistence.OrderRepository"), applicationContext.getBean("orderRepository"), un nom de classe dans un .properties.@ComponentScan dynamique, SpEL, résolution par nom de bean.static final String ou primitif est inséré directement dans le bytecode de l'appelant : la dépendance vers la classe qui le déclare disparaît. Lire une constante d'une couche interdite ne sera donc pas détecté.@AnalyzeClasses, ArchUnit ne voit pas le maillon.ArchUnit protège donc contre ce qui est écrit en dur dans le code, pas contre ce qui est résolu au démarrage. Cela couvre la grande majorité des cas réels — mais un test vert n'est pas une preuve d'isolation.
whereLayer. Aucune vérification n'en découle. Relisez votre règle en comptant les couches d'un côté et les clauses de l'autre.layer() exige au moins une classe, sinon l'infraction Layer 'Service' is empty remonte. C'est votre détecteur de faute de frappe — ne le désactivez pas à la légère. optionalLayer() autorise une couche vide au cas par cas ; withOptionalLayers(true) rend toutes les couches optionnelles (et allowEmptyShould(true) fait la même chose sur cette règle) : à partir de là, une faute de frappe rend le test vert et vide de sens.config, Application, mapper, shared. ensureAllClassesAreContainedInArchitecture() (ou sa variante …Ignoring(...)) exige que chaque classe analysée appartienne à une couche — le seul moyen d'être sûr que la carte couvre le territoire.ImportOption.DoNotIncludeTests, vos classes de test sont analysées, n'appartiennent à aucune couche, et selon le mode choisi soit polluent le rapport, soit passent inaperçues — dans les deux cas le signal se dégrade.🔑 Conclusion clé
Un test d'architecture vert ne vaut que ce que vaut son périmètre. Deux garde-fous à mettre dès le premier jour : ne pas neutraliser l'exigence de couche non vide et exiger que toute classe appartienne à une couche.
FreezingArchRuleLe scénario réaliste : vous écrivez la règle, elle remonte 187 infractions, et personne ne va en corriger 187 cette semaine. Deux mauvaises réponses (commenter le test, ou empiler les ignoreDependency) et une bonne : geler ce qui existe.
@ArchTest
static final ArchRule layers_are_respected = FreezingArchRule.freeze(
layeredArchitecture()
.consideringOnlyDependenciesInAnyPackage("com.myapp..")
.layer("Web").definedBy("com.myapp..web..")
// …
);java# archunit.properties (src/test/resources)
freeze.store.default.path=archunit/violations # ✓ à versionner dans le repo
freeze.store.default.allowStoreCreation=true # premier run : crée le fichier
freeze.store.default.allowStoreUpdate=true # ⚠️ passer à false en CIpropertiesAu premier lancement, les infractions existantes sont écrites dans ce fichier. Ensuite, seules les nouvelles font échouer le test, et celles qui ont été corrigées en sont retirées automatiquement. Le compteur ne peut donc plus que descendre. Comme le fichier est versionné, une relecture voit dans le diff qu'une infraction a été ajoutée ou levée.
⚠️ Discipline requise
Avec allowStoreUpdate=true, la CI pourrait enregistrer les nouvelles infractions au lieu d'échouer. Mettez-le à false sur la CI (ou passez -Darchunit.freeze.store.default.allowStoreUpdate=false), et gardez la mise à jour du fichier comme un geste local, explicite et relu.
C'est la forme la plus répandue dans les projets réels : une interdiction par @ArchTest, écrite avec le langage de base (ArchRuleDefinition) au lieu du langage d'architecture.
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
private static final String SERVICE = "..domain.service..";
private static final String PROVIDER = "..domain.provider..";
private static final String PERSISTENCE = "..domain.persistence..";
// Written from the origin side: no service class may use persistence.
@ArchTest
static final ArchRule services_should_not_depend_on_persistence =
noClasses()
.that().resideInAPackage(SERVICE)
.should().dependOnClassesThat().resideInAPackage(PERSISTENCE)
.as("services must only manipulate domain objects, never persistence objects")
.allowEmptyShould(true); // ⚠️ stays green even if SERVICE matches nothing
// Written from the target side: persistence may only be used by providers (or by itself).
@ArchTest
static final ArchRule persistence_should_only_be_accessed_by_providers =
classes()
.that().resideInAPackage(PERSISTENCE)
.should().onlyHaveDependentClassesThat().resideInAnyPackage(PERSISTENCE, PROVIDER)
.as("persistence objects (repositories and entities) may only be called from providers");java| Élément | Rôle | Variantes courantes |
|---|---|---|
classes() / noClasses() |
le sujet, et le sens : noClasses() interdit ce qui suit au lieu de l'exiger |
aussi methods(), fields(), constructors() |
.that()… |
la sélection : quelles classes sont concernées | resideInAPackage(motif) (un seul motif), resideInAnyPackage(m1, m2…), areAnnotatedWith(...), haveSimpleNameEndingWith(...) |
.should()… |
la condition vérifiée sur les classes sélectionnées | dependOnClassesThat(), onlyHaveDependentClassesThat(), accessClassesThat(), onlyBeAccessed().byAnyPackage(...) |
.as(...) / .because(...) |
remplace / complète la description affichée en cas d'échec | — |
.allowEmptyShould(true) |
fait passer la règle même si la sélection est vide | par défaut, une sélection vide échoue (archRule.failOnEmptyShould) |
Les deux mêmes points de vue qu'au §4, avec un autre vocabulaire :
| Condition écrite à la main | Point de vue | Équivalent en architecture déclarée |
|---|---|---|
dependOnClassesThat() |
l'origine : ce que le sujet utilise | whereLayer(X).mayOnlyAccessLayers(...) / mayNotAccessAnyLayer() |
onlyHaveDependentClassesThat() |
la cible : qui utilise le sujet | whereLayer(X).mayOnlyBeAccessedByLayers(...) / mayNotBeAccessedByAnyLayer() |
accessClassesThat() |
l'origine, mais plus étroit : seulement les appels et accès aux champs écrits dans le corps du code — ni les types de paramètres, ni les types de champs, ni l'héritage | aucun : le langage d'architecture raisonne toujours sur la dépendance complète |
@ArchTest
static final ArchRule domain_layers = layeredArchitecture()
.consideringOnlyDependenciesInAnyPackage("com.myapp.domain..")
.layer("Service").definedBy(SERVICE)
.layer("Provider").definedBy(PROVIDER)
.layer("Persistence").definedBy(PERSISTENCE)
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Provider")
.as("persistence objects (repositories and entities) may only be called from providers");javaDeux différences à noter dans cette traduction :
PERSISTENCE dans la liste des packages autorisés. En architecture déclarée, l'intérieur d'une couche est toujours permis ; avec onlyHaveDependentClassesThat(), il faut inclure le package du sujet lui-même, sinon une entité qui référence une autre entité devient une infraction.Persistence n'est utilisable que par Provider, un accès depuis Service est déjà une infraction. La version écrite à la main garde tout de même un intérêt : son message d'échec pointe l'interdiction précise au lieu de décrire toute l'architecture.⚠️ Les pièges propres à la forme écrite à la main
allowEmptyShould(true) masque les fautes de frappe. Une sélection .that().resideInAPackage("..domain.srevice..") ne correspond à rien : sans ce réglage la règle échoue (bon signal), avec lui elle passe au vert pour toujours. À n'utiliser que sur une couche réellement optionnelle, avec un commentaire qui dit pourquoi.provider → service ou persistence → service n'est pas couvert : il n'existe pas d'équivalent de ensureAllClassesAreContainedInArchitecture() pour un ensemble de règles isolées. L'architecture déclarée, elle, oblige à parler de toutes les couches déclarées."..domain.service.." correspond à tous les modules ; préfixer par la racine ("com.myapp.domain.service..") évite qu'une règle d'un module contraigne un autre par accident.🔑 Conclusion clé
Les deux écritures produisent des ArchRule de même nature et cohabitent dans la même classe de test. Le choix est éditorial : les règles isolées disent « ceci est interdit » (message précis, couverture partielle), l'architecture déclarée dit « voici la structure » (couverture complète des couches déclarées, message plus long).
layeredArchitecture ou autre chose ?| Critère | layeredArchitecture() |
onionArchitecture() |
Règles à la main (§🔟) |
|---|---|---|---|
| Modèle imposé | aucun : autant de couches que vous voulez | hexagonal / clean : domainModels, domainServices, applicationServices, adapter(...) |
aucun |
| Verbosité | 1 règle pour toute la structure | 1 règle, encore plus courte | 1 règle par interdiction |
| Vérifications offertes d'office | aucune | domaine indépendant, adaptateurs isolés les uns des autres | aucune |
| Message d'erreur | décrit toute l'architecture | idem | ciblé, très lisible |
| Bon pour | couches classiques (web/service/persistence), découpages maison | ports & adapters assumé | interdits ponctuels : ..api.. ne doit pas utiliser ..internal.., aucune classe ne doit utiliser java.util.Date… |
Quand choisir quoi. Un découpage en couches nommées, même non canonique → layeredArchitecture(). Une architecture hexagonale déjà en place, avec plusieurs adaptateurs à isoler les uns des autres → onionArchitecture(), qui encode gratuitement « les adaptateurs ne se parlent pas » et « le domaine ne dépend de rien ». Quelques interdits transverses qui ne dessinent pas des couches → règles à la main, dont les messages sont plus parlants. Les trois cohabitent très bien dans la même classe de test, sur la même analyse.
⚡ TL;DR — chaque concept en une ligne
Test d'architecture ✓ Vérifie qui a le droit d'appeler qui entre vos packages, et casse le build au commit fautif. ⚠ Ne juge pas la qualité du découpage — il verrouille aussi fidèlement un mauvais découpage qu'un bon.
layeredArchitecture()
✓ Déclare N couches par motif de package, puis qui peut utiliser chacune ; produit une seule ArchRule.
⚠ Ne vérifie que les couches munies d'un whereLayer — déclarer une couche ne la protège pas.
whereLayer(x).mayOnlyBeAccessedByLayers(...) / mayOnlyAccessLayers(...)
✓ La même interdiction vue de la cible (« qui peut m'utiliser ») ou de l'origine (« qui je peux utiliser »).
⚠ L'intérieur d'une couche est toujours permis, et une couche sans clause reste totalement libre.
Règles écrites à la main noClasses().that()…should()…
✓ Une interdiction par règle, avec un message d'échec précis : dependOnClassesThat() côté origine, onlyHaveDependentClassesThat() côté cible.
⚠ onlyHaveDependentClassesThat() doit inclure le package du sujet, sinon l'intérieur de la couche devient une infraction — et tout ce qui n'est pas écrit reste autorisé.
consideringOnlyDependenciesInAnyPackage("com.myapp..")
✓ Le bon réglage par défaut : tout votre code compte, le JDK et les bibliothèques externes sont ignorés.
⚠ consideringOnlyDependenciesInLayers() rend invisible tout ce qui est hors couches ; consideringAllDependencies() fait échouer les règles écrites côté origine sur une simple dépendance vers String.
Lecture du bytecode avec ASM
✓ Analyse les .class sans charger les classes : rapide, sans application démarrée, sans effet de bord.
⚠ Ne voit que ce qui est écrit en dur dans le code — réflexion, getBean("nom"), JPQL et constantes recopiées sont invisibles.
layer() non vide par défaut
✓ Une faute de frappe dans un motif échoue avec Layer 'X' is empty : c'est un garde-fou, pas une gêne.
⚠ withOptionalLayers(true) / allowEmptyShould(true) le neutralise pour toutes les couches d'un coup.
ensureAllClassesAreContainedInArchitecture()
✓ Exige que chaque classe analysée appartienne à une couche : plus de zone grise (config, util, mapper).
⚠ Sur un projet existant, c'est la clause qui remonte le plus d'infractions d'un coup — à introduire avec un …Ignoring(...) ciblé.
FreezingArchRule
✓ Gèle les infractions existantes dans un fichier versionné ; seules les nouvelles échouent, les corrigées disparaissent.
⚠ Avec allowStoreUpdate=true en CI, le build enregistre les régressions au lieu de les signaler.
🎓 À retenir
considering* différents ne testent pas la même chose — c'est la première ligne à lire quand on relit un test d'architecture.consideringAllDependencies() n'est exploitable que du côté cible. Écrit côté origine, chaque type du JDK devient une infraction : la règle finit désactivée, ce qui est pire que pas de règle du tout.definedBy sans whereLayer élargit le périmètre sans rien vérifier, et sa présence donne un faux sentiment de sécurité.config, Application ou mapper peuvent court-circuiter toute la structure sans qu'aucune règle ne bronche, jusqu'à ce que ensureAllClassesAreContainedInArchitecture() les révèle.static final primitifs et String chez l'appelant : la dépendance n'existe plus dans le bytecode. Même chose pour tout ce qui est résolu par nom au démarrage.accessClassesThat() et dependOnClassesThat() ne testent pas la même chose. Le premier ne voit que les appels et accès aux champs ; un type de paramètre ou un extends passe à travers. Pour une frontière de couche, c'est dependOnClassesThat() qu'il faut.ignoreDependency(A.class, B.class) avec un because explicite laisse une trace relue en revue ; un test commenté, non.FreezingArchRule existe précisément pour rendre cet accord tenable sur un projet déjà cassé.ArchRuleDefinition, support JUnit 5, options d'analyse, FreezingArchRule, propriétés de configurationArchitectures.LayeredArchitecture — signatures exactes, ensureAllClassesAreContainedInArchitecture, variantes d'ignoreDependencyArchitectures.java (source) — sémantique précise des DependencySettings et de withOptionalLayers telle que documentée dans le code004_What_to_Check.adoc — exemples canoniques de layeredArchitecture() et onionArchitecture()