Software Architecture

Imposer les limites de modules : Utiliser ArchUnit et les vérifications au moment de la compilation pour prévenir le déclin du monolithe modulaire

Dans le domaine du développement d'entreprise Java, le « monolithe modulaire » a connu un regain d'intérêt comme alternative pragmatique à la décomposition prématurée en microservices. Cependant, sans garde-fous architecturaux stricts, ces systèmes souffrent souvent d'une « entropie architecturale », où les modules finissent par se mélanger, créant un couplage étroit qui rend l'extraction future en microservices douloureuse ou impossible. Ce déclin est rarement intentionnel ; il se produit par commodité. Pour y remédier, nous devons aller au-delà des conventions et imposer les limites au niveau de la compilation.

Le problème : le couplage implicite dans les monolithes modulaires

Un monolithe modulaire bien conçu traite chaque module comme un futur microservice potentiel. Cela signifie que le Module A ne doit jamais accéder directement aux entités internes du Module B. Au lieu de cela, il doit interagir avec le Module B via une API bien définie. En pratique, les développeurs contournent souvent ces API lorsqu'il est « plus facile » d'appeler directement un dépôt ou un service. Avec le temps, ces raccourcis s'accumulent, transformant le monolithe en une « grosse boule de boue ».

Les revues de code manuelles ne sont pas évolutives à cette fin. Un développeur peut manquer une dépendance subtile lors d'une refactorisation complexe. Nous avons besoin d'une vérification automatisée et continue.

Présentation d'ArchUnit : le test unitaire architectural

ArchUnit est une bibliothèque puissante pour la plateforme Java qui vous permet d'écrire des « tests unitaires architecturaux ». Ces tests sont des tests JUnit classiques qui vérifient la structure de votre code. Si une règle est violée, la compilation échoue, empêchant le mauvais code d'être fusionné.

Implémentation pratique : définir les limites

Supposons que nous ayons un monolithe modulaire avec deux modules : order-service et inventory-service. Nous voulons nous assurer que order-service ne peut accéder à inventory-service que via le package com.myapp.inventory.api, et non via ses classes d'implémentation internes.


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.noClasses;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;

public class ArchitectureTest {

    private final JavaClasses classes = new ClassFileImporter().importPackages("com.myapp");

    @Test
    public void order_service_should_not_access_inventory_implementation() {
        ArchRule rule = noClasses().that().resideInAPackage("com.myapp.order..")
                .should().dependOnClassesThat().resideInAPackage("com.myapp.inventory.internal..");

        rule.check(classes);
    }

    @Test
    public void modules_should_only_interact_via_api_packages() {
        ArchRule rule = slices().matching("com.myapp.(*)..")
                .should().notDependOnEachOther()
                .allowAccessBetween("com.myapp.order..", "com.myapp.inventory.api..");

        rule.check(classes);
    }
}

Intégration avec CI/CD et les outils de compilation

Pour que ces vérifications soient efficaces, elles doivent faire partie du processus de compilation standard. Dans un projet Maven ou Gradle, les tests ArchUnit sont exécutés pendant la phase test. Si un développeur tente d'ajouter une dépendance directe d'une classe du service de commande à une classe interne de l'inventaire, la compilation échouera immédiatement.

Ce cycle de rétroaction immédiat est crucial. Il transforme la conformité architecturale d'un audit a posteriori en une contrainte de développement en temps réel. De plus, vous pouvez étendre cette approche en utilisant FreezingArchRule d'ArchUnit pour gérer le code legacy. Si vous avez des violations existantes, vous pouvez les « geler », permettant à la compilation de passer tout en empêchant les nouvelles violations. Cela rend la refactorisation de grandes bases de code legacy réalisable.

Aller plus loin : imposer les dépendances entre couches

Au-delà des limites de modules, vous pouvez imposer la séparation des couches au sein des modules. Par exemple, en vous assurant que les Controllers n'accèdent pas directement aux Repositories, les forçant à passer par les couches Service. ArchUnit rend cela simple :


@Test
public void controllers_should_not_access_repositories() {
    ArchRule rule = noClasses().that().resideInAPackage("..controller..")
            .should().dependOnClassesThat().resideInAPackage("..repository..");
    rule.check(classes);
}

Conclusion

Les monolithes modulaires offrent le meilleur des deux mondes : la déployabilité d'un seul artefact et la clarté structurelle des microservices. Cependant, cette clarté ne se maintient pas d'elle-même. En intégrant ArchUnit dans votre pipeline de compilation, vous créez un gardien automatisé qui préserve votre intention architecturale. Il transforme une bonne architecture d'un objectif à poursuivre en une contrainte à satisfaire, garantissant que votre système reste robuste, testable et prêt pour une évolution future.

Share: