🪧 Introduction
Tu demandes à ton assistant IA d’ajouter une validation des coordonnées GPS sur la recherche de magasins. Deux minutes plus tard, le code est là. Il compile, les tests passent, la fonctionnalité marche.
Puis tu regardes de plus près. L’IA a créé un package helpers qui n’existait pas. Elle a ajouté un endpoint qui renvoie directement l’entité JPA. Et elle a injecté le service avec un @Autowired sur un champ, alors que toute l’équipe utilise l’injection par constructeur.
Rien de tout ça n’est un bug. Mais rien de tout ça ne respecte l’architecture que ton équipe a choisie.
De plus en plus d’équipes développent avec l’IA. La vraie question n’est plus seulement « est-ce que le code marche ? », mais « est-ce qu’il respecte nos choix ? ». Dans cet article, on va voir comment poser des garde-fous d’architecture avec ArchUnit, pour guider l’IA dans le cadre que tu t’es fixé.
Dans cet article, on va voir :
- pourquoi l’IA ne respecte pas spontanément ton architecture ;
- comment transformer tes choix d’architecture en tests ;
- comment faire de ces tests une consigne que l’IA lit et applique ;
- ce que ces garde-fous ne couvrent pas.
1. 🤖 L’IA connaît le code, pas tes décisions
Un assistant IA écrit le code le plus probable, à partir de ce qu’il a vu ailleurs. Et ailleurs, l’injection par champ et les packages utils sont partout. Il ne connaît pas les raisons de tes choix : pourquoi le domaine ne dépend pas de Spring, pourquoi les adaptateurs ne se parlent pas.
Tu peux le lui écrire dans un fichier d’instructions (AGENTS.md, ou CLAUDE.md pour Claude Code). Ça aide, mais une instruction se lit, elle ne se vérifie pas. La documentation de Claude Code le dit elle-même : ces fichiers sont traités comme du contexte, pas comme une configuration imposée.
Et la revue de code ne suffit plus. Quand l’IA produit quatre cents lignes en quelques minutes, un import mal placé passe facilement inaperçu.
👉 Il faut un garde-fou qui s’exécute tout seul, qui ne se fatigue pas, et dont le message d’erreur peut être lu par un humain comme par une IA.
2. 🧭 Une philosophie, pas un dogme
Avant d’écrire la moindre règle, un point important : il n’existe pas une bonne architecture. Hexagonale, en couches, monolithe modulaire… chaque équipe fait ses choix en fonction de son contexte, de sa taille, de son métier et de son histoire.
Les règles de cet article s’appuient sur une architecture hexagonale, mais ce sont des propositions, pas un modèle à copier. L’idée n’est pas d’imposer une architecture, mais de rendre exécutables les décisions que ton équipe a déjà prises, idéalement documentées dans des ADR.
Une règle que personne n’a décidée gêne plus qu’elle n’aide. Une règle qui reprend un choix de l’équipe protège ce choix.
3. ⚙️ Mettre en place ArchUnit
ArchUnit est une bibliothèque Java qui analyse le bytecode de tes classes et vérifie des règles d’architecture sous forme de tests unitaires. Une règle violée, c’est un test rouge.
On part d’un projet Spring Boot 4.1 organisé en hexagone :
com.example.shop
├── domain
│ ├── model → Store, Position
│ └── service → DistanceCalculator
├── application → StoreSearchService, ports in/out
└── adapter
├── in.web → StoreController
└── out.persistence → StoreEntity, StorePersistenceAdapter
Étape 1 – Ajouter la dépendance
Spring Boot 4 embarque JUnit 6, pour lequel ArchUnit fournit un artefact dédié depuis la version 1.5 :
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit6</artifactId>
<version>1.5.1</version>
<scope>test</scope>
</dependency>
👉 Sur un projet Spring Boot 3 (JUnit 5), utilise archunit-junit5.
Étape 2 – Créer la classe de test
@AnalyzeClasses(packages = "com.example.shop", importOptions = ImportOption.DoNotIncludeTests.class)
public class ArchitectureTest {
// rules go here
}
4. 🛡️ Des garde-fous à adapter à ton projet
Garde-fou 1 – La forme générale de l’architecture
ArchUnit propose une règle toute faite pour l’architecture en oignon, très proche de l’hexagonale :
@ArchTest
static final ArchRule hexagonal_architecture =
onionArchitecture()
.domainModels("..domain.model..")
.domainServices("..domain.service..")
.applicationServices("..application..")
.adapter("web", "..adapter.in.web..")
.adapter("persistence", "..adapter.out.persistence..")
.because("see docs/adr/0004-hexagonal-architecture.md");
Elle vérifie que le domaine ne dépend de rien, que l’application ne dépend pas des adaptateurs, et que les adaptateurs ne se parlent pas entre eux. L’endpoint qui renvoie l’entité JPA est détecté :
Architecture Violation [Priority: MEDIUM] - Rule 'Onion architecture consisting of
domain models ('..domain.model..')
domain services ('..domain.service..')
application services ('..application..')
adapter 'web' ('..adapter.in.web..')
adapter 'persistence' ('..adapter.out.persistence..'), because see docs/adr/0004-hexagonal-architecture.md' was violated (1 times):
Method <com.example.shop.adapter.in.web.StoreController.raw()> has generic return type <java.util.List<com.example.shop.adapter.out.persistence.StoreEntity>> with type argument depending on <com.example.shop.adapter.out.persistence.StoreEntity> in (StoreController.java:0)
Garde-fou 2 – Pas de nouvelle couche sans décision
C’est le garde-fou contre les envolées architecturales : un package helpers, utils ou manager qui apparaît sans que personne ne l’ait décidé.
@ArchTest
static final ArchRule no_new_layer =
classes().should().resideInAnyPackage(
"com.example.shop",
"com.example.shop.domain..",
"com.example.shop.application..",
"com.example.shop.adapter..")
.because("adding a new layer requires an ADR, see docs/adr/");
Architecture Violation [Priority: MEDIUM] - Rule 'classes should reside in any package ['com.example.shop', 'com.example.shop.domain..', 'com.example.shop.application..', 'com.example.shop.adapter..'], because adding a new layer requires an ADR, see docs/adr/' was violated (1 times):
Class <com.example.shop.helpers.GeoUtils> does not reside in any package ['com.example.shop', 'com.example.shop.domain..', 'com.example.shop.application..', 'com.example.shop.adapter..'] in (GeoUtils.java:0)
👉 Ajouter une couche reste possible. Mais ça devient une décision de l’équipe, pas une initiative de l’IA.
Garde-fou 3 – Le domaine reste indépendant du framework
@ArchTest
static final ArchRule domain_independent_of_framework =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..")
.because("see docs/adr/0003-framework-independent-domain.md");
Garde-fou 4 – Les habitudes que l’IA reproduit
La classe GeneralCodingRules contient des règles prêtes à l’emploi, qui ciblent justement des pratiques très répandues dans le code public :
@ArchTest
static final ArchRule no_field_injection = NO_CLASSES_SHOULD_USE_FIELD_INJECTION;
Architecture Violation [Priority: MEDIUM] - Rule 'no classes should use field injection, because field injection is considered harmful; use constructor injection or setter injection instead; see https://stackoverflow.com/q/39890849 for detailed explanations' was violated (1 times):
Field <com.example.shop.adapter.in.web.StoreController.searchNearbyStores> is annotated with @Autowired in (StoreController.java:0)
Selon tes conventions, tu peux aller plus loin : imposer un suffixe Controller aux contrôleurs, interdire les cycles entre modules…
@ArchTest
static final ArchRule controllers_in_web_adapter =
classes().that().areAnnotatedWith(RestController.class)
.should().resideInAPackage("..adapter.in.web..")
.andShould().haveSimpleNameEndingWith("Controller");
@ArchTest
static final ArchRule no_cycles =
slices().matching("com.example.shop.(*)..").should().beFreeOfCycles();
Un contrôleur nommé StoreResource, et un autre posé dans le package application : la règle des contrôleurs remonte les deux.
Architecture Violation [Priority: MEDIUM] - Rule 'classes that are annotated with @RestController should reside in a package '..adapter.in.web..' and should have simple name ending with 'Controller'' was violated (2 times):
Class <com.example.shop.adapter.in.web.StoreResource> does not have simple name ending with 'Controller' in (StoreResource.java:0)
Class <com.example.shop.application.StatsController> does not reside in a package '..adapter.in.web..' in (StatsController.java:0)
Et si un objet du domaine se met à dépendre d’un port de l’application, la règle des cycles l’explique, en listant les dépendances de chaque côté (message raccourci) :
Architecture Violation [Priority: MEDIUM] - Rule 'slices matching 'com.example.shop.(*)..' should be free of cycles' was violated (1 times):
Cycle detected: Slice application ->
Slice domain ->
Slice application
1. Dependencies of Slice application
- Method <com.example.shop.application.port.out.StoreRepository.findAll()> has generic return type <java.util.List<com.example.shop.domain.model.Store>> ...
2. Dependencies of Slice domain
- Field <com.example.shop.domain.model.StoreCatalog.repository> has type <com.example.shop.application.port.out.StoreRepository> in (StoreCatalog.java:0)
5. 🔁 Le test devient une consigne pour l’IA
Un assistant IA qui travaille en mode agent peut lancer les tests, lire les erreurs et corriger son code. Les messages d’ArchUnit lui disent quelle classe viole quelle règle, et le because(...) lui dit où trouver la raison.
👉 Écris donc tes because(...) pour le lecteur, humain ou IA : une phrase claire et un lien vers l’ADR valent mieux qu’un code d’erreur.
Il reste à dire à l’IA de lancer ces tests. C’est justement ce que recommande le format AGENTS.md : indiquer les commandes de test à exécuter avant de terminer une tâche. Par exemple :
## Architecture
- Le projet suit une architecture hexagonale, décrite dans docs/adr/.
- Avant de terminer une tâche, lance `mvn test`. La classe ArchitectureTest
vérifie le respect de l'architecture.
- Ne modifie jamais ArchitectureTest ni src/test/resources/archunit_store.
Si une règle te bloque, explique pourquoi au lieu de la contourner.
🎯 Résultat : l’instruction dit à l’IA ce qu’on attend d’elle, et le test vérifie qu’elle l’a fait.
6. 🔒 Protéger les garde-fous
Une IA bloquée par un test peut être tentée de « corriger » le test plutôt que le code. L’instruction ci-dessus limite le risque, mais la vraie protection se met en place côté dépôt.
Sur GitHub, un fichier CODEOWNERS impose la relecture d’une personne précise sur certains fichiers :
/src/test/java/com/example/shop/architecture/ @my-org/architecture
/src/test/resources/archunit_store/ @my-org/architecture
Associé à la protection de branche « Require review from Code Owners », toute modification des règles passe par une relecture humaine. GitLab propose le même mécanisme dans ses offres Premium et Ultimate.
7. 🧊 Sur un projet existant : geler l’existant
Sur un projet qui a quelques années, la première exécution remonte souvent des dizaines de violations. FreezingArchRule enregistre les violations existantes et ne fait échouer le build que sur les nouvelles :
@ArchTest
static final ArchRule no_field_injection = freeze(NO_CLASSES_SHOULD_USE_FIELD_INJECTION);
# src/test/resources/archunit.properties
freeze.store.default.path=src/test/resources/archunit_store
freeze.store.default.allowStoreCreation=true
Au premier passage, ArchUnit enregistre les violations existantes et le test passe. Ensuite, toute nouvelle violation fait échouer le build, et chaque violation corrigée disparaît du store.
👉 C’est particulièrement utile avec l’IA : l’existant est toléré, mais rien de nouveau ne vient s’ajouter.
💡 Important
Une fois le store créé, repasse
allowStoreCreationàfalse(la valeur par défaut). Si le store disparaît, le build échoue avecStoreInitializationFailedExceptionau lieu d’en recréer un qui accepterait toutes les violations du moment.
8. 🌍 Et en dehors de Java ?
ArchUnit est propre à la JVM, mais la même idée existe dans d’autres écosystèmes :
- C# : ArchUnitNET, le portage d’ArchUnit par la même équipe, utilisable avec xUnit, NUnit ou MSTest ;
- Kotlin : Konsist, dont les règles s’écrivent en tests JUnit ou Kotest, avec une vérification des couches (
assertArchitecture) ; - TypeScript / JavaScript : ArchUnitTS, inspiré d’ArchUnit et utilisable avec Jest ou Vitest, ou dependency-cruiser, qui vérifie des règles de dépendances et fait échouer le build en cas de violation ;
- Python : import-linter, avec des contrats de couches, d’imports interdits ou d’indépendance entre modules ;
- PHP : Deptrac, qui définit des couches et les dépendances autorisées entre elles.
La syntaxe change, la démarche reste la même : traduire tes décisions en règles qui s’exécutent.
9. 💥 Ce que ces garde-fous ne font pas
ArchUnit vérifie les dépendances et la structure. C’est une bonne partie de l’architecture, mais pas toute.
- La sur-ingénierie à l’intérieur d’une couche lui échappe : cinq interfaces pour une seule implémentation, une fabrique inutile, une abstraction « au cas où ».
- Il ne juge pas le métier : un mauvais découpage ou un concept mal nommé passe sans problème.
- Il ne voit que le bytecode : une dépendance créée par réflexion ou par configuration lui échappe.
- Trop de règles tuent les règles : commence par deux ou trois garde-fous qui comptent vraiment, puis ajoute-en au fil des décisions.
👉 La revue de code et les échanges d’équipe restent indispensables. ArchUnit ne les remplace pas : il leur évite de passer du temps sur ce qu’une machine peut vérifier.
🏁 Conclusion
L’IA écrit vite, et souvent bien. Mais elle ne connaît pas les choix de ton équipe. ArchUnit ne garantit pas une architecture parfaite. Il limite le terrain de jeu de l’IA au cadre que vous vous êtes fixé, et il lui explique pourquoi quand elle en sort.
Prends ces règles comme des pistes, pas comme un modèle : garde celles qui traduisent tes décisions, adapte les autres, et ajoute les tiennes.
