← Retour au blog
Architecture

Faire respecter une architecture hexagonale avec du code

Konsist, ArchUnit, eslint-plugin-boundaries : rendre une architecture hexagonale exécutable en automatisant la vérification des règles de dépendance entre couches.

📅 ✍️ Antoine Coulon
hexagonal-architecturekonsistarchunitfitness-functionskotlin

Une architecture n’existe vraiment que si elle est respectée. On peut documenter une architecture hexagonale dans un wiki, la dessiner sur un tableau blanc, la rappeler en revue de code : tant que rien n’empêche concrètement un développeur de faire dépendre le domaine d’un adaptateur, la frontière n’est qu’une convention. Et les conventions, à mesure qu’une équipe grandit et que la pression monte, finissent toujours par s’éroder.

La vraie question n’est donc pas « comment décrire notre architecture ? » mais « comment la rendre exécutable ? ». Comment transformer une règle de dépendance entre couches en quelque chose qu’une machine peut vérifier, de façon répétable, à chaque commit. C’est exactement le rôle des outils de vérification d’architecture, et c’est la pratique que nous avons commencé à appliquer dans mon équipe chez sunday.

Pourquoi automatiser la vérification de l’architecture

À mesure que les équipes grandissent, l’écart se creuse entre l’architecture telle qu’elle a été pensée et l’architecture telle qu’elle est réellement implémentée. Personne n’introduit volontairement une dépendance interdite : ça arrive par méconnaissance d’une règle, par facilité dans un moment de rush, ou tout simplement parce que la frontière n’était écrite nulle part de façon contraignante. Une fois la première violation introduite, elle sert de précédent, et l’érosion s’accélère.

S’appuyer uniquement sur la revue de code pour détecter ces dérives est fragile. La revue dépend de la vigilance d’un humain qui doit, en plus de tout le reste, garder en tête l’ensemble des règles de dépendance du projet. C’est le genre de tâche dans laquelle l’humain est mauvais et la machine excellente : vérifier mécaniquement, sans fatigue et sans exception, qu’un ensemble de contraintes est respecté.

D’où l’intérêt d’outiller cette vérification. Plutôt que d’espérer que les standards soient respectés, on les rend impossibles à enfreindre sans que la pipeline le signale. L’architecture passe alors du statut de documentation passive à celui de propriété testable du système, ce qu’on appelle souvent une fitness function : une fonction automatisée qui mesure si le code respecte une caractéristique architecturale attendue.

Un outil par écosystème

La bonne nouvelle, c’est que cette idée est suffisamment répandue pour qu’il existe un outil mature dans la plupart des écosystèmes. Les trois que nous avons regardés de près :

Le principe est partout le même : on déclare les couches (ou modules) de l’architecture, puis on exprime les relations de dépendance autorisées et interdites entre elles. L’outil parcourt ensuite le code source et signale toute violation.

Les règles d’une architecture hexagonale

Rappelons rapidement les couches d’une architecture hexagonale (ou ports & adapters), car ce sont elles que nous allons contraindre :

Dans ce contexte, les règles de dépendance que l’on veut faire respecter sont les suivantes :

  1. Le Domain ne dépend ni des Use Cases, ni des APIs, ni des SPIs. C’est le noyau : il ignore tout du monde qui l’entoure.
  2. Les APIs (Primary Adapters) dépendent du domaine, mais ne dépendent pas directement des SPIs.
  3. Les SPIs (Secondary Adapters) dépendent des Domain Ports et les implémentent. Ils peuvent aussi dépendre des APIs d’un autre module (bounded context).
  4. Les Use Cases dépendent du domaine et des Domain Ports, mais ne dépendent pas directement des SPIs.

Exprimées en prose, ces règles ressemblent à des bonnes intentions. Le but est de les transformer en code exécuté.

En pratique avec Kotlin et Konsist

Voici comment ces quatre règles se traduisent concrètement avec Konsist. On déclare chaque couche comme un Layer identifié par son package racine, puis on exprime les relations dependsOn / doesNotDependOn attendues. Le tout est encapsulé dans un test : il sera donc exécuté aussi souvent que la suite de tests, en local comme en intégration continue.

class ArchitectureRules {

    @Test
    fun `Architecture dependencies rules`() {
        Konsist.scopeFromProduction()
            .assertArchitecture {
                // Infrastructure, Primary Adapters (API) and Secondary Adapters (SPI)
                val apiLayer = Layer(name = "Infrastructure_API", rootPackage = "com.<package>..infrastructure.api..")
                val spiLayer = Layer(name = "Infrastructure_SPI", rootPackage = "com.<package>..infrastructure.spi..")
                // Domain
                val domainLayer = Layer(name = "Domain", rootPackage = "com.<package>..domain..")
                val domainPortLayer = Layer(name = "Domain_Ports", rootPackage = "com.<package>..domain..ports..")
                // Use Cases
                val useCases = Layer(name = "Use_Cases", rootPackage = "com.<package>..usecases..")

                // API and SPI are the ones depending on domain layer and domain ports
                apiLayer.dependsOn(domainLayer)
                apiLayer.doesNotDependOn(spiLayer)
                spiLayer.doesNotDependOn(apiLayer)
                spiLayer.dependsOn(domainPortLayer, apiLayer)

                // Use cases should depend on domain layer and domain ports, but not on API or SPI
                useCases.dependsOn(domainLayer, domainPortLayer)
                useCases.doesNotDependOn(apiLayer, spiLayer)

                // Domain does not depend on API or SPI
                domainLayer.doesNotDependOn(apiLayer, spiLayer)
                domainPortLayer.doesNotDependOn(apiLayer, spiLayer)
            }
    }
}

Ce qui est intéressant ici, c’est la correspondance directe entre l’intention et le code. Le commentaire « Domain does not depend on API or SPI » se lit immédiatement dans les deux appels domainLayer.doesNotDependOn(...) et domainPortLayer.doesNotDependOn(...) qui le suivent. Il n’y a plus d’écart possible entre la règle énoncée et la règle vérifiée : elles sont une seule et même chose. Le jour où quelqu’un introduit un import du domaine vers un adaptateur, ce test échoue, et la dérive est arrêtée avant même d’atteindre la branche principale.

C’est cette propriété qui fait la valeur de l’approche : l’architecture devient un test, c’est-à-dire un filet de sécurité actif, et non plus un document que l’on consulte avec espoir.

La feedback loop pourrait aller plus loin

Intégrer ces règles dans la suite de tests est déjà un progrès considérable : les tests sont exécutés très fréquemment, en local comme dans la pipeline d’intégration continue, et une violation est donc détectée rapidement. C’est une excellente première étape.

L’idéal, toutefois, serait de raccourcir encore la boucle de feedback. Plutôt que d’attendre l’exécution des tests, on aimerait que ces règles soient signalées directement dans l’IDE, au fil de l’écriture, à la manière d’un ESLint. Côté Kotlin, cela passerait par un linter comme Detekt : l’erreur architecturale apparaîtrait alors comme un avertissement immédiat, à l’endroit même où la mauvaise dépendance est introduite, au lieu d’être découverte après coup.

C’est d’ailleurs un avantage naturel de eslint-plugin-boundaries dans l’écosystème JavaScript / TypeScript : étant un plugin ESLint, il fournit ce retour instantané dans l’éditeur. La leçon est que la valeur d’une règle d’architecture automatisée croît avec la rapidité à laquelle elle remonte l’information. Plus le signal arrive tôt, moins la correction coûte cher.

Conclusion

Faire respecter une architecture avec du code, c’est refuser de laisser les frontières les plus importantes d’un système reposer sur la seule discipline collective. Konsist, ArchUnit et eslint-plugin-boundaries partagent la même promesse : transformer des règles de dépendance énoncées en prose en contraintes vérifiées par la machine, de façon répétable et sans exception.

Le gain ne se mesure pas seulement en bugs évités, mais en clarté préservée dans le temps. Une architecture hexagonale dont les couches sont contrôlées automatiquement reste fidèle à son intention initiale même après des dizaines de contributeurs et des centaines de commits. Commencer par des tests d’architecture est simple et accessible ; viser ensuite un retour dans l’IDE est la prochaine marche. Dans les deux cas, le principe reste le même : une architecture que l’on rend exécutable est une architecture qui dure.