Skip to main content
David Le Montagner Home Page David Le Montagner

Utilisation de ArchUnit pour assurer le respect des principes de l’architecture hexagonale

Pourquoi contrôler le respect des principes d’architecture? #

Au cours de ma carrière, j’ai souvent eu l’opportunité de travailler sur des projets legacy. Bien souvent, l’historique du projet et les changements d’équipe successifs se ressentent. Il est possible de déceler ici et là les intentions initiales de l’architecture de l’application mais celles-ci se sont perdues au fil du temps.

Au delà des projets legacy, il est important de reconnaître que les développeurs ne sont pas infaillibles et peuvent faire des erreurs. Même avec une compréhension solide des principes architecturaux, il est facile de dévier de la trajectoire souhaitée par l’équipe.

C'est là qu'intervient ArchUnit, une librairie open-source en Java qui permet de définir et de contrôler des règles d’architecture logicielle. Avec ArchUnit, vous pouvez définir des règles spécifiques à votre architecture et vérifier si votre code les respecte. Cela peut être utilisé pour imposer des conventions de nommage, des dépendances entre packages, des règles d'encapsulation, et bien plus encore.

L’autre intérêt de ArchUnit est que les règles qui sont énoncées peuvent également faire office de documentation listant ainsi les contraintes d’architecture à respecter par les développeurs de l’équipe.

Exemple: respect des principes de l’architecture hexagonale avec ArchUnit #

Pour illustrer la fonctionnalité, je vais reprendre le projet de l’article que j’ai écrit concernant les transactions dans une architecture hexagonale. Nous allons le mettre à jour pour y intégrer les règles ArchUnit qui permettront de valider l’architecture. A noter qu’en intégrant ces règles, j’ai justement identifié une erreur dans ce qui avait été implémenté. Comme quoi les tests, y compris d’architecture, sont toujours utiles 😉 J’en ai également profité pour réorganiser les packages afin que l’emplacement de chacun des éléments soit plus clair.

Initialisation de la classe de test #

S’agissant de tests, nous allons créer la classe HexagonalArchitectureTest dans src/test/java/dlemontagner/moneytransfer/architecture :

package dlemontagner.moneytransfer.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.lang.syntax.ArchRuleDefinition.classes;

@AnalyzeClasses(packages = "dlemontagner.moneytransfer", importOptions = { ImportOption.DoNotIncludeTests.class })
public class HexagonalArchitectureTest {
    @ArchTest
    static final ArchRule requestsClassesShouldEndWithRequest =
		classes()
			.that()
			.resideInAPackage("..infrastructure.requests..")
			.should()
			.haveSimpleNameEndingWith("Request")
			.andShould()
			.beRecords();
}

Avec l’annotation AnalyzeClasses, nous indiquons à ArchUnit que nous souhaitons analyser les classes du package “dlemontagner.moneytransfer” à l’exception des tests.

Puis nous créons une première règle qui consiste à valider que les classes représentant les requêtes, présentes dans le package “infrastructure.requests” doivent être des Records et avoir un nom qui se termine par Request.

Comme vous pouvez le constater, le nom des méthodes de ArchUnit permettent d’avoir des règles qui parlent d’elles-même.

A noter qu’il est possible d’écrire la même chose de la manière ci-dessous mais nous adopterons la première méthode dans la suite de l’article:

package dlemontagner.moneytransfer.architecture;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.jupiter.api.Test;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;

public class HexagonalArchitectureAltSyntaxTest {
    @Test
    public void requestsClassesShouldEndWithRequest() {
        JavaClasses importedClasses = new ClassFileImporter().withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS).importPackages("dlemontagner.moneytransfer");

        ArchRule requestsClassesShouldEndWithRequest =
                classes()
                        .that()
                        .resideInAPackage("..infrastructure.requests..")
                        .should()
                        .haveSimpleNameEndingWith("Request")
                        .andShould()
                        .beRecords();

        requestsClassesShouldEndWithRequest.check(importedClasses);
    }
}

Que se passe-t-il lorsqu’une règle n’est pas respectée? #

Si je crée une classe “ARequestClassThatDoesNotMeetArchUnitRules” dans le package “infrastructure.requests”, ArchUnit retournera l’erreur suivante lors de l’exécution des tests:

java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package '..infrastructure.requests..' should have simple name ending with 'Request' and should be records' was violated (2 times):
Class <dlemontagner.moneytransfer.infrastructure.requests.ARequestClassThatDoesNotMeetArchUnitRules> does not have simple name ending with 'Request' in (ARequestClassThatDoesNotMeetArchUnitRules.java:0)
Class <dlemontagner.moneytransfer.infrastructure.requests.ARequestClassThatDoesNotMeetArchUnitRules> is no record in (ARequestClassThatDoesNotMeetArchUnitRules.java:0)

	at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
	at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
	at com.tngtech.archunit.lang.ArchRule$Factory$SimpleArchRule.check(ArchRule.java:165)
	at com.tngtech.archunit.lang.syntax.ObjectsShouldInternal.check(ObjectsShouldInternal.java:81)
	<....>

L’erreur est suffisamment claire et précise pour que le développeur puisse rapidement se conformer à la convention de nommage sans pour autant aller voir le code ArchUnit associé.

Toutefois, si ceci n’est pas suffisant, il est possible de définir le message de la manière suivante:

ArchRule requestsClassesShouldEndWithRequest =
        classes()
                .that()
                .resideInAPackage("..infrastructure.requests..")
                .should()
                .haveSimpleNameEndingWith("Request")
                .andShould()
                .beRecords()
                .as("Request objects should be records and have name ending with Request")
                .because("Request objects should be immutable and with appropriate naming");

Dans ce cas, l’erreur affichera ce qui a été décrit:

java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'Request objects should be records and have name ending with Request, because Request objects should be immutable and with appropriate naming' was violated (2 times):
Class <dlemontagner.moneytransfer.infrastructure.requests.ARequestClassThatDoesNotMeetArchUnitRules> does not have simple name ending with 'Request' in (ARequestClassThatDoesNotMeetArchUnitRules.java:0)
Class <dlemontagner.moneytransfer.infrastructure.requests.ARequestClassThatDoesNotMeetArchUnitRules> is no record in (ARequestClassThatDoesNotMeetArchUnitRules.java:0)

Validation des principes de l’architecture hexagonale #

Maintenant que le fonctionnement de ArchUnit est plus clair, passons à l'écriture des autres règles. Il existe parfois plusieurs façons d'implémenter une règle, celles définies ci-dessous sont une proposition que vous pouvez évidemment ajuster en fonction de vos besoins et/ou des autres possibilités offertes par ArchUnit.

Vue globale de l’architecture #

Pour nous aider à écrire les bonnes règles, voici une vue globale de l’architecture adoptée par notre application avec le nommage des packages ainsi que les dépendances entre chacun d’eux.

Vue globale de l'architecture hexagonale cible

Le domaine doit être préservé de toute dépendance #

Pour commencer, nous pouvons énoncer la règle la plus importante de l’architecture hexagonale qui est de préserver le domaine de toute dépendance autre que Java:

  @ArchTest
  static final ArchRule domainShouldHaveNoDependencies =
      noClasses()
              .that()
              .resideInAPackage("..domain..")
              .should()
              .dependOnClassesThat()
              .resideOutsideOfPackages("..domain..","..ddd..","..java..");

Nous autorisons une dépendance vers “ddd” afin d’accéder aux annotations relatives au ddd.

Grace à cette règle, nous complétons le découpage réalisé via Maven pour nous assurer que le domaine ne sera jamais “pollué” par l’usage d’une librairie ou d’un framework technique. De plus, si ceci devenait nécessaire dans le futur, l’équipe sera en mesure de compléter la règle pour autoriser explicitement la librairie ou le framework autorisé à être utilisé par le domaine.

Les services doivent être annotés, implémenter un port API et ne pas être appelés directement #

  @ArchTest
  static final ArchRule domainServicesShouldBeInServicesPackage =
      noClasses()
              .that()
              .resideOutsideOfPackage("..domain.services..")
              .should()
              .beAnnotatedWith(DomainService.class);

  @ArchTest
  static final ArchRule domainServicesShouldImplementApiInterfaces =
      classes()
              .that()
              .areAnnotatedWith(DomainService.class)
              .should()
              .implement(resideInAPackage("..domain.ports.api.."));

  @ArchTest
  static final ArchRule noClassesShouldDependOnDomainServices =
      noClasses()
              .should()
              .dependOnClassesThat(resideInAPackage("..domain.services.."))
              .as("No classes should depend on domain services")
              .because("Domain API ports should be used instead");

Les ports API et SPI sont des interfaces exceptés pour les stubs qui résident dans un package dédié #

  @ArchTest
  static final ArchRule domainPortsShouldBeInterfacesExceptForStubs =
      classes()
              .that()
              .resideInAPackage("..domain.ports..")
              .and()
              .resideOutsideOfPackage("..domain.ports.spi.stubs..")
              .should()
              .beInterfaces();

  @ArchTest
  static final ArchRule domainStubsShouldBeInSpiStubPackage =
      noClasses()
              .that()
              .resideOutsideOfPackage("..domain.ports.spi.stubs..")
              .should()
              .beAnnotatedWith(Stub.class);

Les ports API et SPI ne dépendent pas les uns des autres exceptés pour les stubs #

  @ArchTest
  static final ArchRule apiAndSpiPortsShouldNotDependOnEachOthersExceptForStubs =
      noClasses()
              .that()
              .resideInAPackage("..domain.ports..")
              .and()
              .resideOutsideOfPackage("..domain.ports.spi.stubs..")
              .should()
              .dependOnClassesThat()
              .resideInAnyPackage("..domain.ports.api..","..domain.ports.spi..");

Les adapteurs API et SPI ne dépendent pas l’un de l’autre #

  @ArchTest
  static final ArchRule adaptersShouldNotDependOnOneAnother = 
      slices()
              .matching("..infrastructure.(adapters).(*)..").namingSlices("$1 '$2'")
              .should().notDependOnEachOther();

Les adapteurs ne doivent pas être appelés directement #

  @ArchTest
  static final ArchRule adaptersShouldNotBeCalledDirectly =
      noClasses()
              .that()
              .resideOutsideOfPackages("..infrastructure.adapters.spi..", "..infrastructure.adapters.api..")
              .should()
              .dependOnClassesThat(resideInAnyPackage("..infrastructure.adapters.spi..", "..infrastructure.adapters.api.."));

Les ressources doivent être de type Record et respecter une convention de nommage #

  @ArchTest
  static final ArchRule resourcesClassesShouldEndWithResource =
      classes()
              .that()
              .resideInAPackage("..infrastructure.resources..")
              .should()
              .haveSimpleNameEndingWith("Resource")
              .andShould()
              .beRecords();;

Regroupement des règles #

Pour davantage de clarté mais aussi pour être capable d’exporter facilement un ensemble de règles, nous pouvons utiliser la capacité de ArchUnit à exécuter l’ensemble des tests présents dans une classe donnée.

Ainsi, nous déplaçons toutes les règles dans une classe HexagonalArchitectureRules:

public class HexagonalArchitectureRules {
    @ArchTest
    static final ArchRule domainShouldHaveNoDependencies =
            noClasses()
                    .that()
                    .resideInAPackage("..domain..")
                    .should()
                    .dependOnClassesThat()
                    .resideOutsideOfPackages("..domain..","..ddd..","..java..");

    @ArchTest
    static final ArchRule domainServicesShouldBeInServicesPackage =
    <...>
	
}

Puis nous renommons la classe HexagonalArchitectureTest en ArchitectureTest pour permettre, si besoin, l’intégration de règles qui ne sont pas liées à l’architecture hexagonale. Enfin, nous utilisons la méthode ArchTests.in pour exécuter les règles présentes dans HexagonalArchitectureRules:

@AnalyzeClasses(packages = "dlemontagner.moneytransfer")
public class ArchitectureTest {
    @ArchTest
    public static final ArchTests hexagonalArchTests = ArchTests.in(HexagonalArchitectureRules.class);
}

Limites #

Malgré toutes les fonctionnalités disponibles, j’ai fait face à une limitation de ArchUnit.

Je n’ai pas réussi à valider que les contrôleurs ne retournent pas directement les objets métiers, c’est à dire que ne doivent être retournés que des objets ResponseEntity où T fait partie du package “..resources..”.

Ceci est dû au fait que ArchUnit travaille sur le bytecode et que le type erasure a eu lieu. En effet, il est possible de contrôler un type de retour “brut” via haveRawReturnType() mais, dans ce cas, ArchUnit voit un objet ResponseEntity ce qui ne répond pas au besoin. Il n’existe pas, à ce jour, de méthode haveReturnType().

Pour aller plus loin… #

Les règles écrites dans cet article ne représentent qu’un faible échantillon des fonctionnalités de ArchUnit et je vous invite à consulter la documentation pour constater l’étendue des possibilités.

Par ailleurs, dans le contexte d’une entreprise, il est évident qu’il y a un intérêt à créer des règles à respecter sur un scope plus large: par exemple, sur l’ensemble des modules d’un projet ou même au global dans l’entreprise.

Une des façons de partager les règles entre projets serait de les mettre dans un projet Maven dédié puis d’ajouter la dépendance sur les projets qui souhaitent utiliser les règles en question. Il est également possible de créer des classes plus génériques qui prendraient en paramètre le nom des packages du projet pour chacun des éléments de l’architecture.

Par exemple, il serait possible de lancer une validation avec ce type de code:

  @Test
  public void checkForHexagonalArchitectureCompliance() {
	HexagonalArchitecture.basePackage("dlemontagner.moneytransfer")
		.withDomainLayer("domain")
		.incomingAPIPorts("ports.api")
		.outgoingSPIPorts("ports.spi")
		.and()
		.withInfrastructureLayer("infrastructure")
		.incomingAPIAdapters("adapters.api")
		.outgoingSPIAdapters("adapters.spi")
		.requests("requests")
		.resources("resources")
		.check(new ClassFileImporter()
		.importPackages("dlemontagner.moneytransfer.."));
  }

Pour éviter de partir de zéro, vous pouvez vous appuyer sur les architectures prédéfinies de ArchUnit ou sur les règles utilisées par certains projets open-source comme JHipster Lite.

Un autre projet qui semble intéressant est JMolecules dont l'objectif est de faciliter l'expression de concepts architecturaux via des annotations pré-établies. En complément de ces concepts, le projet propose notamment des règles ArchUnit s'appuyant sur ces annotations pour vérifier la bonne application de l'architecture choisie.

Code source #

Retrouvez l'intégralité du code source dans ce repository.

Conclusion #

J’espère vous avoir convaincu que ArchUnit est un outil puissant pour maintenir l'intégrité architecturale de votre application Java. En définissant des règles spécifiques à l'Architecture Hexagonale, vous pouvez garantir que votre code reste cohérent et maintenable. En intégrant ces vérifications dans votre processus de développement, vous pouvez détecter les violations architecturales tôt dans le cycle de développement, ce qui facilite leur correction et garantit la qualité globale de votre code.

Comme vous avez pu le voir, il est possible de valider énormément d’aspects grâce à cet outil. Il est d’ailleurs probable que l’exhaustivité des règles importantes pour votre équipe ne soient pas présentes dès le départ. Ainsi, au même titre que la configuration d’un linter ou d’un sonar, il est important de faire vivre les règles pour y ajouter les non conformités relevées lors des code reviews et ainsi automatiser au maximum l’identification de celles-ci.