Faire tenir l’architecture dans le temps
Vous avez posé les frontières. Un domaine qui ne dépend de rien, des ports en interfaces, des adapters tout autour. C’est le sujet de l’architecture hexagonale avec Spring Boot.
Six mois plus tard, le domaine importe le framework.
Personne n’a pris cette décision. C’est arrivé un vendredi soir, dans un correctif urgent, avec une annotation posée « juste pour cette fois ». La revue est passée dessus. Le code fonctionne. Personne n’a rien remarqué. Mais la règle, elle, n’existe plus.
Cet article parle de la seule chose qui empêche vraiment ça : écrire la règle dans un test, pour que la machine la vérifie à chaque build. On verra pourquoi, puis comment, avec ArchUnit côté Java et Konsist côté Kotlin.
Tous les exemples ci-dessous ont été exécutés, et les sorties affichées sont les vraies, sur ArchUnit 1.4.2 et Konsist 0.17.3.
Une règle que personne ne vérifie n’est pas une règle
Une règle d’architecture vit d’habitude à trois endroits. Un schéma dans un wiki. Un paragraphe dans le README. Et la tête de ceux qui étaient là quand la décision a été prise.
Aucun des trois ne bloque un commit.
Reste la revue de code. Elle fonctionne, à trois conditions : que le relecteur connaisse la règle, qu’il la voie passer dans le diff, et qu’il ait le temps. Ces trois conditions tombent vite. Une personne qui arrive dans l’équipe, un correctif pressé, un diff de 400 lignes un jeudi soir.
Et l’érosion ne ressemble jamais à une grosse infraction. Ce sont vingt petits écarts, et chacun paraît raisonnable si on le regarde seul. Au bout d’un an, plus personne ne sait où passe la frontière, parce qu’il n’y en a plus.
Le compilateur, lui, ne laisse rien passer. Un type qui ne correspond pas, un private qu’on essaie de lire depuis l’extérieur : ça ne compile pas, et ça ne se discute pas. Une règle d’architecture, c’est simplement une règle que le compilateur ne connaît pas encore. Tout le travail consiste à la lui apprendre. Et quand ce n’est pas possible, on l’apprend à un test.
Ce qu’un test d’architecture sait faire
Quatre familles de règles se vérifient mécaniquement, et elles couvrent l’essentiel :
- le sens des dépendances entre packages ou modules, la règle la plus utile de toutes ;
- ce qu’une couche a le droit d’utiliser, par exemple « le domaine n’importe ni le framework web, ni la persistance » ;
- les conventions de placement et de nommage, par exemple « tout ce qui finit par
UseCasevit dans le packageapplication» ; - l’absence de cycles entre packages.
En revanche, aucun outil ne vous dira si la frontière est au bon endroit. Un test vous dit que la règle est respectée. Il ne vous dira jamais qu’elle est bonne. Une mauvaise frontière vérifiée automatiquement reste une mauvaise frontière. Et le jour où vous voudrez la déplacer, le test sera un obstacle de plus.
Un détail pratique compte beaucoup : ces tests sont des tests. Ils tournent avec les autres, ils cassent le build comme les autres, ils apparaissent dans le même rapport. Pas de pipeline en plus, pas d’outil à installer sur le poste de chacun. C’est précisément ce qui fait qu’ils survivent.
D’abord découper, ensuite tester
La règle la plus solide, c’est celle qu’on ne peut pas casser.
Si le domaine est un module de build à part, et que ce module n’a pas le framework dans ses dépendances, alors importer le framework dans le domaine ne compile pas. Il n’y a aucune règle à écrire, aucun outil à maintenir, et l’erreur arrive dans l’IDE avant même le commit.
Faites-le partout où c’est possible. Mais ça ne couvre pas tout. Découper en modules a un coût, et surtout, à l’intérieur d’un module le compilateur se moque de vos packages : domain et adapter côte à côte, pour lui, c’est pareil. C’est exactement là que le test prend le relais.
ArchUnit, sur un projet Java
ArchUnit est une bibliothèque de test. Elle lit les classes compilées de votre projet et vous laisse écrire des règles dessus, dans une API qui se lit presque comme une phrase en anglais.
Une seule dépendance de test :
testImplementation("com.tngtech.archunit:archunit-junit5:1.4.2")
Prenons le découpage de l’article précédent : domain, application, adapter. La première règle est celle qui compte le plus, et c’est la plus courte à écrire.
@AnalyzeClasses(packages = "com.example.account",
importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule le_domaine_ignore_le_framework = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..");
}
Posez maintenant un @Component sur une classe du domaine, comme le ferait le fameux correctif du vendredi soir. Le test tombe :
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'no classes
that reside in a package '..domain..' should depend on classes that reside in any
package ['org.springframework..', 'jakarta.persistence..']' was violated (1 times):
Class <com.example.account.domain.Account> is annotated with
<org.springframework.stereotype.Component> in (Account.java:0)
Le message contient tout ce qu’il faut : la règle en toutes lettres, le nombre de violations, la classe fautive et l’annotation en cause.
Un détail à connaître pour ne pas s’inquiéter : ce Account.java:0. Ce n’est pas un bug. ArchUnit travaille sur le bytecode, et une annotation n’y conserve pas son numéro de ligne. Pour un appel de méthode, la ligne est exacte, comme on va le voir tout de suite.
La règle des couches
La règle précédente interdit une dépendance précise. Celle-ci décrit le sens de circulation entre les couches, ce qui est plus proche de ce qu’on dessine au tableau :
@ArchTest
static final ArchRule les_couches = layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("..domain..")
.layer("Application").definedBy("..application..")
.layer("Adapter").definedBy("..adapter..")
.whereLayer("Adapter").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Adapter")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Adapter");
Faites appeler une classe de l’adapter depuis le domaine, et vous obtenez :
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'Layered
architecture considering all dependencies, consisting of
layer 'Domain' ('..domain..')
layer 'Application' ('..application..')
layer 'Adapter' ('..adapter..')
where layer 'Adapter' may not be accessed by any layer
where layer 'Application' may only be accessed by layers ['Adapter']
where layer 'Domain' may only be accessed by layers ['Application', 'Adapter']'
was violated (1 times):
Method <com.example.account.domain.Account.display()> calls method
<com.example.account.adapter.web.AccountFormatter.format(java.lang.String)>
in (Account.java:9)
Cette fois la ligne est là, Account.java:9. On lit la méthode fautive, celle qu’elle appelle, et où. La correction ne demande aucune enquête.
Deux mots sur les annotations en tête de classe. @AnalyzeClasses déclare le package à analyser. Les classes ne sont lues qu’une fois : ArchUnit les garde en cache, et une autre classe de test qui demande le même package réutilise la même lecture. ImportOption.DoNotIncludeTests exclut vos propres tests de l’analyse, ce que vous voulez presque toujours : un test a le droit de tout connaître.
Le cas du code existant
Sur un projet qui tourne depuis trois ans, la première règle que vous écrivez sortira deux cents violations. Personne ne les corrigera cette semaine. Et une règle qui reste rouge en permanence finit toujours de la même façon : commentée.
FreezingArchRule est faite pour ce moment précis. Elle enregistre les violations existantes dans un fichier versionné avec le code, et ne fait échouer le build que sur les nouvelles.
@ArchTest
static final ArchRule les_couches = FreezingArchRule.freeze(
layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("..domain..")
// ... le reste de la règle, inchangé
);
Avec, dans src/test/resources/archunit.properties, l’autorisation de créer le fichier au premier passage :
freeze.store.default.allowStoreCreation=true
Le comportement est exactement celui qu’on espère. Au premier passage, les violations en place sont enregistrées dans archunit_store/ et le build passe. Ajoutez-en une nouvelle, et seule celle-là est signalée :
Method <com.example.account.application.Extra.go()> calls method
<com.example.account.adapter.web.AccountFormatter.format(java.lang.String)>
in (Extra.java:4)
Et ça marche dans les deux sens. Corrigez une violation gelée, elle disparaît du fichier. Réintroduisez-la ensuite, elle n’est plus acceptée et le build casse. Le nombre de violations ne peut donc que baisser, sans jamais bloquer l’équipe le jour où la règle arrive.
Konsist, sur un projet Kotlin
Sur un projet entièrement en Kotlin, Konsist fait le même travail. Il lit vos fichiers source et expose ce qu’il y trouve, classes, fonctions, imports, packages, sous forme d’une API qu’on enchaîne comme une collection.
Là encore, une dépendance de test :
testImplementation("com.lemonappdev:konsist:0.17.3")
Reprenons les deux mêmes règles. La première, le domaine qui ignore le framework, se pose sur les imports des fichiers du domaine :
@Test
fun `le domaine ignore le framework`() {
Konsist
.scopeFromProduction()
.files
.filter { it.packagee?.name?.startsWith("com.example.account.domain") == true }
.assertFalse { it.hasImport { import -> import.name.startsWith("org.springframework") } }
}
scopeFromProduction() prend le code de production et laisse les tests de côté. Le reste se lit tout seul : parmi ces fichiers, ceux du domaine, et aucun ne doit avoir d’import Spring.
Remettez l’annotation fautive, et Konsist nomme le fichier :
com.lemonappdev.konsist.core.exception.KoAssertionFailedException:
Assert 'le domaine ignore le framework' was violated (1 time). Invalid files:
└── File Account.kt file:///.../src/main/kotlin/com/example/account/domain/Account.kt
La règle des couches a sa propre API, avec des couches nommées et des dépendances déclarées :
@Test
fun `les couches respectent le sens des dependances`() {
Konsist
.scopeFromProduction()
.assertArchitecture {
val domain = Layer("Domain", "com.example.account.domain..")
val application = Layer("Application", "com.example.account.application..")
val adapter = Layer("Adapter", "com.example.account.adapter..")
domain.dependsOnNothing()
application.dependsOn(domain)
adapter.dependsOn(application, domain)
}
}
Le même écart que tout à l’heure, le domaine qui appelle l’adapter, donne :
com.lemonappdev.konsist.core.exception.KoAssertionFailedException:
'les couches respectent le sens des dependances' test has failed.
'Domain' layer should not depend on anything but has dependencies in files:
└── File file:///.../src/main/kotlin/com/example/account/domain/Account.kt
└── Import com.example.account.adapter.web.AccountFormatter (file:///.../Account.kt:3:1)
Le fichier, l’import fautif, la ligne et la colonne.
Deux choses à savoir avant de lancer ça sur votre projet.
D’abord, Konsist cherche la racine du projet en remontant les dossiers, et il lui faut un .git pour la trouver. Sans lui, le test échoue sur un Project directory not found qui n’a rien à voir avec vos règles. Dans un vrai dépôt, la question ne se pose pas.
Ensuite, la règle de couches se lit sur les imports. Une dépendance écrite en nom pleinement qualifié, sans import, passe donc au travers :
class Account(val id: String) {
fun display() = com.example.account.adapter.web.AccountFormatter.format(id)
}
Ce code appelle bien l’adapter depuis le domaine, et le test reste vert. En pratique, personne n’écrit du Kotlin comme ça. Mais autant le savoir, pour ne pas croire la règle plus large qu’elle ne l’est.
Par où commencer
N’écrivez pas quinze règles le premier jour. Écrivez-en une, celle dont l’équipe a déjà parlé deux fois en revue. Une règle utile qui tourne vaut mieux qu’un jeu complet que personne ne respecte.
Vérifiez ensuite que le message d’échec dit quoi faire. Les deux outils nomment le fichier fautif, c’est déjà l’essentiel. Donnez à vos tests des noms qui expliquent l’intention plutôt que la mécanique : quelqu’un les lira un matin sans contexte, avec un build rouge et une mise en production à faire.
Sur du code existant, gelez. Une règle rouge en permanence ne protège rien, elle apprend juste à l’équipe à ignorer une ligne rouge de plus.
Et ne mettez sous test que les règles sur lesquelles l’équipe est d’accord. Un test d’architecture n’est pas un moyen de gagner un débat de conception. C’est un moyen de ne pas avoir à le refaire tous les trois mois.
En résumé
Une règle d’architecture qui n’est écrite que dans un schéma ne survit pas à six mois de sprints. La revue de code ne suffit pas, parce qu’elle dépend de la mémoire et de la disponibilité de quelqu’un.
Quand c’est possible, faites porter la règle par le découpage en modules : ce qui ne compile pas ne se discute pas. Pour le reste, un test.
C’est ce qu’on vient de voir avec ArchUnit sur un projet Java et Konsist sur un projet Kotlin : ils font respecter la règle à chaque build. C’est ce qui garantit vos frontières dans le temps.
Sur du code existant, gelez les violations en place et ne faites échouer le build que sur les nouvelles. Leur nombre ne peut alors que baisser.
Et rappelez-vous ce qu’un test ne fera jamais : vous dire que la frontière est au bon endroit. Ça, ça reste votre travail.